merge: 合并 dev 并解决 PR 冲突
This commit is contained in:
@@ -8,3 +8,4 @@
|
||||
/data/account-run-history.json
|
||||
.npm-test.log
|
||||
.omx/
|
||||
/node_modules
|
||||
@@ -53,6 +53,7 @@
|
||||
- 自动显示当前使用中的密码,便于后续保存
|
||||
- 自动获取注册验证码与登录验证码
|
||||
- 支持 `Hotmail`:继续使用 `邮箱 + 客户端 ID + 刷新令牌(refresh token)`,并可在远程服务与本地助手两种模式间切换
|
||||
- 支持 `2925`:新增多账号池、自动登录登出、Step 4 / Step 8 命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号
|
||||
- 支持 `QQ Mail`、`163 Mail`、`163 VIP Mail`、`126 Mail`、`Inbucket mailbox`
|
||||
- 支持从 DuckDuckGo Email Protection 自动生成新的 `@duck.com` 地址
|
||||
- 支持基于 Cloudflare 自定义域名自动生成随机邮箱前缀
|
||||
@@ -80,33 +81,34 @@
|
||||
## 安装
|
||||
|
||||
1. 打开 `chrome://extensions/`
|
||||
2. 开启“开发者模式”
|
||||
3. 点击“加载已解压的扩展程序”
|
||||
4. 选择本项目目录
|
||||
5. 打开扩展侧边栏
|
||||
|
||||
## 2026-04-17 更新补充:Gmail / 2925 别名邮箱
|
||||
|
||||
本次版本对 `Gmail` 与 `2925` 的注册邮箱逻辑做了统一整理:
|
||||
本次版本对 `Gmail` 与 `2925 provide` 的注册邮箱逻辑做了统一整理:
|
||||
|
||||
- `Gmail` 与 `2925` 现在都走同一套“别名邮箱”逻辑。
|
||||
- `Gmail` 与 `2925 provide` 现在都走同一套“别名邮箱”逻辑。
|
||||
- 两者都不再使用“只填前缀再特殊拼接”的界面交互。
|
||||
- 两者都要求先填写“基邮箱”:
|
||||
- `Gmail`:例如 `name@gmail.com`
|
||||
- `2925`:例如 `name@2925.com`
|
||||
- `2925`(仅 provide 模式):例如 `name@2925.com`
|
||||
- 侧边栏里的“注册邮箱”输入框对这两种模式都已开放,可直接手动填写完整邮箱。
|
||||
- 侧边栏里的 `获取 / 生成` 按钮对这两种模式也可用,行为与 Duck / Cloudflare 一样,都是“可自动生成,也可手动覆盖”。
|
||||
- 当 `Mail = 2925` 且模式切到 `接收邮箱` 时,不再走别名基邮箱链路,而是回退到普通“邮箱生成 / 手动填写注册邮箱”路线,2925 只负责后续收信。
|
||||
|
||||
具体行为:
|
||||
|
||||
- `Gmail` 会基于完整基邮箱生成 `name+tag@gmail.com`
|
||||
- `2925` 会基于完整基邮箱生成 `name123456@2925.com`
|
||||
- `2925` 仅在 provide 模式下会基于完整基邮箱生成 `name123456@2925.com`
|
||||
- 如果当前“注册邮箱”里已经是与当前基邮箱兼容的完整邮箱,流程会优先复用,不会强行重新生成
|
||||
|
||||
注意:
|
||||
|
||||
- `2925` 旧的“只填前缀”使用方式已经不再推荐,应该改为填写完整基邮箱
|
||||
- 如果你手动填写了与当前 `Gmail / 2925` 基邮箱不匹配的完整邮箱,侧边栏会在保存或执行 Step 3 时拦截
|
||||
2. 开启“开发者模式”
|
||||
3. 点击“加载已解压的扩展程序”
|
||||
4. 选择本项目目录
|
||||
5. 打开扩展侧边栏
|
||||
- 如果你手动填写了与当前 `Gmail / 2925 provide` 基邮箱不匹配的完整邮箱,侧边栏会在保存或执行 Step 3 时拦截
|
||||
|
||||
## 快速开始
|
||||
|
||||
@@ -130,7 +132,15 @@
|
||||
4. Step 1 会直接在 SUB2API 后台生成 OAuth 链接
|
||||
5. Step 10 会把 localhost 回调提交回 SUB2API,并直接创建 OpenAI 账号
|
||||
|
||||
### 方案 C:`Hotmail 账号池`
|
||||
### 方案 C:`Codex2API + QQ / 163 / 163 VIP / 126`
|
||||
|
||||
1. `来源` 选择 `Codex2API`
|
||||
2. 填好 `Codex2API` 后台地址、管理密钥
|
||||
3. `Mail` 与 `邮箱生成` 的配置方式同方案 A
|
||||
4. Step 7 会直接通过 Codex2API 协议 `/api/admin/oauth/generate-auth-url` 生成 OAuth 链接
|
||||
5. Step 10 会把 localhost 回调中的 `code / state` 通过 `/api/admin/oauth/exchange-code` 直接提交给 Codex2API
|
||||
|
||||
### 方案 D:`Hotmail 账号池`
|
||||
|
||||
1. `Mail` 选择 `Hotmail`
|
||||
2. 在 `Hotmail 账号池` 中添加 `邮箱 / Client ID / Refresh Token`
|
||||
@@ -138,6 +148,20 @@
|
||||
4. 通过后再执行步骤或 `Auto`
|
||||
5. 当前项目中,`Mail = Hotmail` 时会直接使用账号池里的邮箱作为注册邮箱,不再走 `Duck / Cloudflare` 自动生成
|
||||
|
||||
### 方案 E:`2925 账号池`
|
||||
|
||||
1. `Mail` 选择 `2925`
|
||||
2. 在 `2925 账号池` 中添加 `邮箱 / 密码`
|
||||
3. 先根据你的用途选择 `2925 模式`
|
||||
- `提供邮箱`:注册邮箱本身就是 2925 别名,会显示“别名基邮箱”输入
|
||||
- `接收邮箱`:注册邮箱回退到普通“邮箱生成 / 手动填写”路线,2925 只负责收信
|
||||
4. `2925 号池` 现在是独立配置行;开启 `号池` 后可从下拉框中选择当前 2925 账号。若当前处于 `提供邮箱` 模式,这个账号也会同步作为别名基邮箱
|
||||
5. 可先点 `使用此账号` 让当前 2925 账号切到这条记录,再点 `登录` 手动验证网页邮箱登录态
|
||||
6. 只有在 `号池` 开关开启时,自动流程执行到 Step 4 / Step 8 前才会自动检查 2925 登录态;如果未登录,会先清理登录 cookie、等待 `3 秒`,再打开登录页,并在页面打开后再等待 `3 秒`,然后使用当前账号自动登录;填写完账号密码后会额外等待 `1 秒` 再点击登录,点击后若 `40 秒`内仍未进入收件箱,则会判定当前登录失败
|
||||
7. 当 Step 4 / Step 8 轮询邮箱时遇到“子邮箱已达上限邮箱”,扩展会记录当前时间;如果还有下一个可用账号,就禁用当前账号 24 小时并自动切换登录;如果没有下一个可用账号,或当前未启用号池模式,则会直接复用现有“手动暂停 / 停止”逻辑终止自动流程
|
||||
8. 如果你同时开启了 `Auto` 的自动重试,当前尝试结束后会按现有逻辑自动进入下一次尝试,不需要再手动介入
|
||||
9. 只有 `Mail = 2925` 且模式为 `提供邮箱` 时,才会继续走 Gmail / 2925 共用的别名邮箱链路;例如 `name@2925.com -> name123456@2925.com`
|
||||
|
||||
## 侧边栏配置说明
|
||||
|
||||
### `CPA`
|
||||
@@ -161,11 +185,26 @@ Step 1 和 Step 10 都依赖这个地址。
|
||||
|
||||
插件会在 Step 1 和 Step 10 自动从 `/api/v1/admin/proxies/all` 解析这个代理,并在 OAuth 链接生成、授权码交换和账号创建请求中附带 `proxy_id`。如果名称匹配到多个代理,请改填代理 ID;留空则不会发送 `proxy_id`。
|
||||
|
||||
### `Codex2API`
|
||||
|
||||
当 `来源 = Codex2API` 时,需要配置:
|
||||
|
||||
- `Codex2API`:后台账号管理页地址,默认 `http://localhost:8080/admin/accounts`
|
||||
- `管理密钥`:Codex2API 的 `Admin Secret`
|
||||
|
||||
插件会在:
|
||||
|
||||
- Step 7 调用 `POST /api/admin/oauth/generate-auth-url` 生成授权链接
|
||||
- Step 10 调用 `POST /api/admin/oauth/exchange-code` 完成 localhost callback 的授权码交换并创建账号
|
||||
|
||||
这条来源是协议直连,不依赖 Codex2API 后台页面的“添加账号 / OAuth 授权 / 生成授权链接”按钮 DOM。
|
||||
|
||||
### `Mail`
|
||||
|
||||
支持五种验证码来源:
|
||||
支持七种验证码来源:
|
||||
|
||||
- `Hotmail`
|
||||
- `2925`
|
||||
- `163 Mail`
|
||||
- `163 VIP Mail`
|
||||
- `126 Mail`
|
||||
@@ -175,6 +214,7 @@ Step 1 和 Step 10 都依赖这个地址。
|
||||
说明:
|
||||
|
||||
- `Hotmail` 通过侧边栏里的 Hotmail 账号池选择账号,可切换为远程服务模式或本地助手模式
|
||||
- `2925` 通过侧边栏里的 2925 账号池选择账号,并在 Step 4 / Step 8 前自动校验网页邮箱登录态
|
||||
- `QQ`、`163`、`163 VIP`、`126` 用于直接轮询网页邮箱
|
||||
- `Inbucket` 通过你在侧边栏里配置的 host 访问 `mailbox` 页面:`https://<your-inbucket-host>/m/<mailbox>/`
|
||||
|
||||
@@ -208,6 +248,24 @@ Step 1 和 Step 10 都依赖这个地址。
|
||||
- 校验通过后,可点击 `测试收信`
|
||||
- Auto 模式每轮会自动选用一个可用账号
|
||||
|
||||
### `2925 账号池`
|
||||
|
||||
仅当 `Mail = 2925` 时使用。
|
||||
|
||||
每条账号支持保存:
|
||||
|
||||
- `email`
|
||||
- `password`
|
||||
|
||||
使用方式:
|
||||
|
||||
- 添加一个或多个 `2925.com` 账号
|
||||
- 点击 `使用此账号` 可以切换当前别名基邮箱来源
|
||||
- 点击 `登录` 可以让扩展直接打开 / 复用 2925 邮箱标签页,并使用当前账号自动登录
|
||||
- 如果账号因为“子邮箱已达上限邮箱”被临时禁用,列表里会展示上限记录时间和恢复时间
|
||||
- 命中冷却的账号可手动 `清冷却`,也可以等待 24 小时后自动恢复可用
|
||||
- Auto 模式在生成 2925 别名邮箱前,会自动分配当前可用账号;若 Step 4 / Step 8 命中上限,会自动切到下一个账号
|
||||
|
||||
#### 本地 helper 启动命令
|
||||
|
||||
Windows:
|
||||
@@ -493,7 +551,7 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
||||
- 使用第 2 步已经确定好的邮箱
|
||||
- 使用自定义密码或自动生成密码
|
||||
- 在密码页填写密码并提交注册表单
|
||||
- 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路
|
||||
- 后台会在真正把 Step 3 记为完成前,再确认页面是否已经推进;如果此时出现认证页 `重试` 页面,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路
|
||||
|
||||
实际使用的密码会写入会话状态,并同步到侧边栏显示。
|
||||
|
||||
@@ -501,9 +559,10 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
||||
|
||||
根据 `Mail` 配置,轮询邮箱并提取 6 位验证码。
|
||||
|
||||
进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果密码页出现 `糟糕,出错了 / 操作超时(Operation timed out)` 并带有 `重试` 按钮,会先通过共享恢复逻辑最多自动点击 5 次 `重试`,回到密码页重新提交,再继续等待验证码页面。
|
||||
进入邮箱轮询前,脚本会先确认认证页是否已经进入验证码页面;如果注册认证流程出现 `糟糕,出错了 / 操作超时(Operation timed out)`,或 `/email-verification` 上的 `405 / Route Error` 且带有 `重试` 按钮,会先通过共享恢复逻辑最多自动点击 5 次 `重试`,必要时回到密码页重新提交,再继续等待验证码页面。
|
||||
|
||||
在 `Auto` 模式下,如果 Step 4 当前轮失败,后台不会立刻丢弃这轮邮箱;而是沿用当前邮箱回到 Step 1 重新开始当前轮,避免刚拿到的邮箱被直接换掉。
|
||||
但如果 Step 4 的认证重试页正文里出现 `user_already_exists`,则会直接判定“当前用户已存在”,不会点击 `重试`,而是立即结束当前轮;开启自动重试时会直接继续下一轮。
|
||||
|
||||
支持:
|
||||
|
||||
@@ -548,9 +607,11 @@ Cloudflare 模式下,插件不会再调用 Cloudflare API 创建路由。
|
||||
|
||||
- 已刷新到最新 OAuth 链接
|
||||
- 认证页已经真正进入登录验证码页面
|
||||
- 在真正把 Step 7 记为完成前,还会再做一轮收尾确认;如果页面只是短暂进入登录验证码页、随后又掉进登录重试页,则不会直接进入 Step 8,而是先按共享恢复逻辑处理并重跑 Step 7
|
||||
- 如遇登录超时报错,会先尝试通过共享恢复逻辑最多自动点击 5 次认证页上的 `重试` 恢复当前页面;若仍未恢复,再按既有逻辑重跑整个 Step 7
|
||||
- 如遇登录页长时间停滞,会由后台刷新 OAuth 后重跑整个 Step 7
|
||||
- 如果重试页内容中出现 `max_check_attempts`,会立刻完全停止流程,并在侧边栏复用现有确认弹窗提示这是 Cloudflare 风控拦截,确认按钮显示为“我知道了”
|
||||
- Step 8 不负责替代 Step 7 的收尾确认;它默认消费的是“已经由 Step 7 确认稳定进入”的登录验证码页,只在后台入口做防御性状态兜底
|
||||
|
||||
支持:
|
||||
|
||||
@@ -567,7 +628,7 @@ Step 8 默认要求当前认证页已经处于登录验证码页。
|
||||
- 打开邮箱并轮询登录验证码
|
||||
- 填写并提交登录验证码
|
||||
- 验证码链路失败后按有限次数回退到 Step 7
|
||||
- 如果进入登录超时报错/重试页,会直接报错并回到 Step 7,不会在 Step 8 内部点击 `重试`
|
||||
- 如果进入登录超时报错/重试页,包括 `/email-verification` 上的 `405 / Route Error` 登录重试页,会直接报错并回到 Step 7,不会在 Step 8 内部点击 `重试`
|
||||
- 如果重试页内容中出现 `max_check_attempts`,会直接完全停止整个流程,并复用现有确认弹窗提醒先等待 15 到 30 分钟或更换浏览器,确认按钮显示为“我知道了”
|
||||
|
||||
与 Step 4 类似,但会使用稍微不同的关键词组合去找登录验证码邮件。
|
||||
@@ -586,7 +647,7 @@ Step 8 默认要求当前认证页已经处于登录验证码页。
|
||||
- 等待按钮可点击
|
||||
- 获取按钮坐标
|
||||
- 通过 Chrome `debugger` 的输入事件点击该按钮
|
||||
- 点击后会持续检查页面是否真正离开当前状态;如果出现认证页 `重试` 页面,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再重新执行当前轮的“继续”点击
|
||||
- 点击后会持续检查页面是否真正离开当前状态;如果点击后出现认证页 `重试` 页面,则直接报错,不会在 Step 9 内部点击 `重试`
|
||||
- 同时监听 `chrome.webNavigation.onBeforeNavigate`
|
||||
- 一旦捕获本地回调地址,就把结果保存到 `Callback`
|
||||
|
||||
@@ -602,9 +663,9 @@ Step 8 默认要求当前认证页已经处于登录验证码页。
|
||||
|
||||
- 步骤 10 会拒绝任何不是真实 `/auth/callback`,或缺少 `code` / `state` 的本地回调地址
|
||||
- 成功后的清理只会针对 `/auth` 这一类真实回调标签页,不会再泛化清理任意 localhost 路径
|
||||
- 侧边栏可切换“本地 CPA”策略,默认是 `全部回调`
|
||||
- 选择 `全部回调` 时,即使 CPA 部署在本地,也会执行步骤 10
|
||||
- 选择 `跳过第10步` 时,仅当本地 CPA 且步骤 9 已拿到回调地址时,才会直接跳过步骤 10
|
||||
- 侧边栏可切换“回调方式”,默认是 `服务器部署`
|
||||
- 选择 `服务器部署` 时,即使 CPA 部署在本地,也会执行步骤 10
|
||||
- 选择 `本地部署` 时,仅当本地 CPA 且步骤 9 已拿到回调地址时,才会直接跳过步骤 10
|
||||
|
||||
回到 CPA 面板:
|
||||
|
||||
|
||||
+677
-45
File diff suppressed because it is too large
Load Diff
@@ -39,9 +39,9 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
function extractFailedStep(status = '', detail = '') {
|
||||
function extractRecordStep(status = '', detail = '') {
|
||||
const normalizedStatus = String(status || '').trim().toLowerCase();
|
||||
const statusMatch = normalizedStatus.match(/^step(\d+)_failed$/);
|
||||
const statusMatch = normalizedStatus.match(/^step(\d+)_(?:failed|stopped)$/);
|
||||
if (statusMatch) {
|
||||
const step = Number(statusMatch[1]);
|
||||
return Number.isInteger(step) && step > 0 ? step : null;
|
||||
@@ -73,6 +73,9 @@
|
||||
return '流程完成';
|
||||
}
|
||||
if (finalStatus === 'stopped') {
|
||||
if (Number.isInteger(failedStep) && failedStep > 0) {
|
||||
return `步骤 ${failedStep} 停止`;
|
||||
}
|
||||
return '流程已停止';
|
||||
}
|
||||
if (finalStatus !== 'failed') {
|
||||
@@ -152,7 +155,7 @@
|
||||
const failedStepCandidate = Number(record.failedStep);
|
||||
const failedStep = Number.isInteger(failedStepCandidate) && failedStepCandidate > 0
|
||||
? failedStepCandidate
|
||||
: extractFailedStep(record.finalStatus || record.status || '', failureDetail);
|
||||
: extractRecordStep(record.finalStatus || record.status || '', failureDetail);
|
||||
const autoRunContext = normalizeAutoRunContext(record.autoRunContext);
|
||||
const retryCount = normalizeRetryCount(
|
||||
record.retryCount !== undefined
|
||||
@@ -160,6 +163,8 @@
|
||||
: ((autoRunContext?.attemptRun || 0) > 1 ? autoRunContext.attemptRun - 1 : 0)
|
||||
);
|
||||
const source = normalizeSource(record.source || (autoRunContext ? 'auto' : 'manual'));
|
||||
const computedFailureLabel = buildFailureLabel(finalStatus, failedStep, failureDetail);
|
||||
const rawFailureLabel = String(record.failureLabel || '').trim();
|
||||
|
||||
return {
|
||||
recordId: String(record.recordId || '').trim() || buildRecordId(email),
|
||||
@@ -168,7 +173,9 @@
|
||||
finalStatus,
|
||||
finishedAt,
|
||||
retryCount,
|
||||
failureLabel: String(record.failureLabel || '').trim() || buildFailureLabel(finalStatus, failedStep, failureDetail),
|
||||
failureLabel: finalStatus === 'stopped'
|
||||
? computedFailureLabel
|
||||
: (rawFailureLabel || computedFailureLabel),
|
||||
failureDetail,
|
||||
failedStep: Number.isInteger(failedStep) && failedStep > 0 ? failedStep : null,
|
||||
source,
|
||||
@@ -215,7 +222,9 @@
|
||||
}
|
||||
|
||||
const failureDetail = finalStatus === 'failed' || finalStatus === 'stopped' ? String(reason || '').trim() : '';
|
||||
const failedStep = finalStatus === 'failed' ? extractFailedStep(status, failureDetail) : null;
|
||||
const failedStep = finalStatus === 'failed' || finalStatus === 'stopped'
|
||||
? extractRecordStep(status, failureDetail)
|
||||
: null;
|
||||
const source = Boolean(state.autoRunning) ? 'auto' : 'manual';
|
||||
const autoRunContext = source === 'auto' ? buildAutoRunContextFromState(state) : null;
|
||||
const retryCount = source === 'auto' ? getRetryCountFromState(state) : 0;
|
||||
@@ -322,6 +331,9 @@
|
||||
}
|
||||
|
||||
function shouldSyncAccountRunHistorySnapshot(state = {}) {
|
||||
if (Boolean(state.contributionMode)) {
|
||||
return false;
|
||||
}
|
||||
if (!Boolean(state.accountRunHistoryTextEnabled)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
hasSavedProgress,
|
||||
isAddPhoneAuthFailure,
|
||||
isRestartCurrentAttemptError,
|
||||
isSignupUserAlreadyExistsFailure,
|
||||
isStopError,
|
||||
launchAutoRunTimerPlan,
|
||||
normalizeAutoRunFallbackThreadIntervalMinutes,
|
||||
@@ -373,6 +374,7 @@
|
||||
emailGenerator: prevState.emailGenerator,
|
||||
gmailBaseEmail: prevState.gmailBaseEmail,
|
||||
mail2925BaseEmail: prevState.mail2925BaseEmail,
|
||||
currentMail2925AccountId: prevState.currentMail2925AccountId,
|
||||
emailPrefix: prevState.emailPrefix,
|
||||
inbucketHost: prevState.inbucketHost,
|
||||
inbucketMailbox: prevState.inbucketMailbox,
|
||||
@@ -458,7 +460,9 @@
|
||||
const reason = getErrorMessage(err);
|
||||
roundSummary.failureReasons.push(reason);
|
||||
const blockedByAddPhone = typeof isAddPhoneAuthFailure === 'function' && isAddPhoneAuthFailure(err);
|
||||
const canRetry = !blockedByAddPhone && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
|
||||
const blockedBySignupUserAlreadyExists = typeof isSignupUserAlreadyExistsFailure === 'function'
|
||||
&& isSignupUserAlreadyExistsFailure(err);
|
||||
const canRetry = !blockedByAddPhone && !blockedBySignupUserAlreadyExists && autoRunSkipFailures && attemptRun < maxAttemptsForRound;
|
||||
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
@@ -499,6 +503,41 @@
|
||||
break;
|
||||
}
|
||||
|
||||
if (blockedBySignupUserAlreadyExists) {
|
||||
roundSummary.status = 'failed';
|
||||
roundSummary.finalFailureReason = reason;
|
||||
await setState({
|
||||
autoRunRoundSummaries: serializeAutoRunRoundSummaries(totalRuns, roundSummaries),
|
||||
});
|
||||
await appendRoundRecordIfNeeded('failed', reason);
|
||||
cancelPendingCommands('当前轮因 user_already_exists 已终止。');
|
||||
await broadcastStopToContentScripts();
|
||||
if (!autoRunSkipFailures) {
|
||||
await addLog(
|
||||
`第 ${targetRun}/${totalRuns} 轮触发 user_already_exists/用户已存在,自动重试未开启,当前自动运行将停止。`,
|
||||
'warn'
|
||||
);
|
||||
stoppedEarly = true;
|
||||
await broadcastAutoRunStatus('stopped', {
|
||||
currentRun: targetRun,
|
||||
totalRuns,
|
||||
attemptRun,
|
||||
sessionId: 0,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
await addLog(`第 ${targetRun}/${totalRuns} 轮触发 user_already_exists/用户已存在,本轮将直接失败并跳过剩余重试。`, 'warn');
|
||||
await addLog(
|
||||
targetRun < totalRuns
|
||||
? `第 ${targetRun}/${totalRuns} 轮因 user_already_exists/用户已存在提前结束,自动流程将继续下一轮。`
|
||||
: `第 ${targetRun}/${totalRuns} 轮因 user_already_exists/用户已存在提前结束,已无后续轮次,本次自动运行结束。`,
|
||||
'warn'
|
||||
);
|
||||
forceFreshTabsNextRun = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (canRetry) {
|
||||
const retryIndex = attemptRun;
|
||||
if (isRestartCurrentAttemptError(err)) {
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
(function attachBackgroundContributionOAuth(root, factory) {
|
||||
root.MultiPageBackgroundContributionOAuth = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundContributionOAuthModule() {
|
||||
const API_BASE_URL = 'https://apikey.qzz.io/oauth/api';
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
|
||||
const CALLBACK_FINAL_STATUSES = new Set(['submitted']);
|
||||
const CALLBACK_WAITING_STATUSES = new Set(['idle', 'waiting', 'captured', 'failed', 'submitting']);
|
||||
|
||||
const RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
|
||||
const RUNTIME_KEYS = Object.keys(RUNTIME_DEFAULTS);
|
||||
|
||||
function createContributionOAuthManager(deps = {}) {
|
||||
const {
|
||||
addLog,
|
||||
broadcastDataUpdate,
|
||||
chrome,
|
||||
closeLocalhostCallbackTabs,
|
||||
getState,
|
||||
setState,
|
||||
} = deps;
|
||||
|
||||
let listenersBound = false;
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value, fallback = 0) {
|
||||
const numeric = Math.floor(Number(value) || 0);
|
||||
return numeric > 0 ? numeric : fallback;
|
||||
}
|
||||
|
||||
function normalizeContributionStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'started':
|
||||
return 'started';
|
||||
case 'waiting':
|
||||
case 'wait':
|
||||
return 'waiting';
|
||||
case 'processing':
|
||||
return 'processing';
|
||||
case 'auto_approved':
|
||||
case 'approved':
|
||||
return 'auto_approved';
|
||||
case 'auto_rejected':
|
||||
case 'rejected':
|
||||
return 'auto_rejected';
|
||||
case 'manual_review_required':
|
||||
case 'manual_review':
|
||||
return 'manual_review_required';
|
||||
case 'expired':
|
||||
case 'timeout':
|
||||
return 'expired';
|
||||
case 'error':
|
||||
case 'failed':
|
||||
return 'error';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeContributionCallbackStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'idle':
|
||||
return 'idle';
|
||||
case 'waiting':
|
||||
case 'pending':
|
||||
return 'waiting';
|
||||
case 'captured':
|
||||
return 'captured';
|
||||
case 'submitting':
|
||||
case 'processing':
|
||||
return 'submitting';
|
||||
case 'submitted':
|
||||
case 'success':
|
||||
case 'done':
|
||||
return 'submitted';
|
||||
case 'failed':
|
||||
case 'error':
|
||||
return 'failed';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isContributionFinalStatus(status = '') {
|
||||
return FINAL_STATUSES.has(normalizeContributionStatus(status));
|
||||
}
|
||||
|
||||
function getStatusLabel(status = '') {
|
||||
switch (normalizeContributionStatus(status)) {
|
||||
case 'started':
|
||||
return '已生成登录地址';
|
||||
case 'waiting':
|
||||
return '等待提交回调';
|
||||
case 'processing':
|
||||
return '已提交回调,等待 CPA 确认';
|
||||
case 'auto_approved':
|
||||
return '贡献成功,CPA 已确认';
|
||||
case 'auto_rejected':
|
||||
return '贡献未通过确认';
|
||||
case 'manual_review_required':
|
||||
return '已提交,等待人工处理';
|
||||
case 'expired':
|
||||
return '贡献会话已超时';
|
||||
case 'error':
|
||||
return '贡献流程失败';
|
||||
default:
|
||||
return '等待开始贡献';
|
||||
}
|
||||
}
|
||||
|
||||
function getCallbackLabel(status = '') {
|
||||
switch (normalizeContributionCallbackStatus(status)) {
|
||||
case 'waiting':
|
||||
case 'idle':
|
||||
return '等待回调';
|
||||
case 'captured':
|
||||
return '已捕获回调地址';
|
||||
case 'submitting':
|
||||
return '正在提交回调';
|
||||
case 'submitted':
|
||||
return '已提交回调';
|
||||
case 'failed':
|
||||
return '回调提交失败';
|
||||
default:
|
||||
return '等待回调';
|
||||
}
|
||||
}
|
||||
|
||||
function unwrapPayload(payload) {
|
||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (payload.data && typeof payload.data === 'object' && !Array.isArray(payload.data)) {
|
||||
return { ...payload.data, ...payload };
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
function getErrorMessage(payload, responseStatus = 500) {
|
||||
const details = [
|
||||
payload?.message,
|
||||
payload?.detail,
|
||||
payload?.error,
|
||||
payload?.reason,
|
||||
]
|
||||
.map((item) => normalizeString(item))
|
||||
.find(Boolean);
|
||||
|
||||
if (details) {
|
||||
return details;
|
||||
}
|
||||
|
||||
return `贡献服务请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
async function fetchContributionJson(endpoint, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 15000));
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: options.method || 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
||||
},
|
||||
body: options.body ? JSON.stringify(options.body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let rawPayload = {};
|
||||
try {
|
||||
rawPayload = await response.json();
|
||||
} catch {
|
||||
rawPayload = {};
|
||||
}
|
||||
|
||||
const payload = unwrapPayload(rawPayload);
|
||||
if (!response.ok || payload.ok === false) {
|
||||
const error = new Error(getErrorMessage(payload, response.status));
|
||||
error.payload = payload;
|
||||
error.responseStatus = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('贡献服务请求超时,请稍后重试。');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function pickContributionState(state = {}) {
|
||||
const picked = {};
|
||||
for (const key of RUNTIME_KEYS) {
|
||||
picked[key] = state[key] !== undefined ? state[key] : RUNTIME_DEFAULTS[key];
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
async function applyRuntimeUpdates(updates = {}) {
|
||||
if (!updates || typeof updates !== 'object' || Array.isArray(updates) || Object.keys(updates).length === 0) {
|
||||
return getState();
|
||||
}
|
||||
|
||||
await setState(updates);
|
||||
broadcastDataUpdate(updates);
|
||||
return getState();
|
||||
}
|
||||
|
||||
function extractAuthStateFromUrl(authUrl = '') {
|
||||
try {
|
||||
return new URL(authUrl).searchParams.get('state') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function buildNickname(state = {}, preferredNickname = '') {
|
||||
const nickname = normalizeString(preferredNickname)
|
||||
|| normalizeString(state.contributionNickname);
|
||||
return nickname || '';
|
||||
}
|
||||
|
||||
function buildContributionQq(state = {}, preferredQq = '') {
|
||||
const qq = normalizeString(preferredQq) || normalizeString(state.contributionQq);
|
||||
return qq;
|
||||
}
|
||||
|
||||
function buildStatusMessage(status, payload = {}) {
|
||||
const label = getStatusLabel(status);
|
||||
const details = [
|
||||
payload.status_message,
|
||||
payload.statusMessage,
|
||||
payload.message,
|
||||
payload.detail,
|
||||
payload.reason,
|
||||
]
|
||||
.map((item) => normalizeString(item))
|
||||
.find(Boolean);
|
||||
|
||||
if (!details || details === label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label}:${details}`;
|
||||
}
|
||||
|
||||
function buildCallbackMessage(status, payload = {}) {
|
||||
const label = getCallbackLabel(status);
|
||||
const details = [
|
||||
payload.callback_message,
|
||||
payload.callbackMessage,
|
||||
payload.message,
|
||||
payload.detail,
|
||||
payload.reason,
|
||||
]
|
||||
.map((item) => normalizeString(item))
|
||||
.find(Boolean);
|
||||
|
||||
if (!details || details === label) {
|
||||
return label;
|
||||
}
|
||||
|
||||
return `${label}:${details}`;
|
||||
}
|
||||
|
||||
function deriveCallbackState(payload = {}, state = {}) {
|
||||
const existingStatus = normalizeContributionCallbackStatus(state.contributionCallbackStatus);
|
||||
const callbackUrl = normalizeString(
|
||||
payload.callback_url
|
||||
|| payload.callbackUrl
|
||||
|| state.contributionCallbackUrl
|
||||
);
|
||||
const explicitStatus = normalizeContributionCallbackStatus(
|
||||
payload.callback_status
|
||||
|| payload.callbackStatus
|
||||
);
|
||||
|
||||
if (explicitStatus) {
|
||||
return {
|
||||
status: explicitStatus,
|
||||
message: buildCallbackMessage(explicitStatus, payload),
|
||||
callbackUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (payload.callback_submitted === true || payload.callbackSubmitted === true) {
|
||||
return {
|
||||
status: 'submitted',
|
||||
message: buildCallbackMessage('submitted', payload),
|
||||
callbackUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (callbackUrl) {
|
||||
return {
|
||||
status: CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured',
|
||||
message: buildCallbackMessage(CALLBACK_FINAL_STATUSES.has(existingStatus) ? existingStatus : 'captured', payload),
|
||||
callbackUrl,
|
||||
};
|
||||
}
|
||||
|
||||
if (CALLBACK_FINAL_STATUSES.has(existingStatus) || existingStatus === 'failed') {
|
||||
return {
|
||||
status: existingStatus,
|
||||
message: normalizeString(state.contributionCallbackMessage) || buildCallbackMessage(existingStatus),
|
||||
callbackUrl: normalizeString(state.contributionCallbackUrl),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'waiting',
|
||||
message: buildCallbackMessage('waiting', payload),
|
||||
callbackUrl: '',
|
||||
};
|
||||
}
|
||||
|
||||
function isContributionCallbackUrl(rawUrl, state = {}) {
|
||||
const urlText = normalizeString(rawUrl);
|
||||
if (!urlText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(urlText);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const code = normalizeString(parsed.searchParams.get('code'));
|
||||
const errorText = normalizeString(parsed.searchParams.get('error'))
|
||||
|| normalizeString(parsed.searchParams.get('error_description'));
|
||||
const authState = normalizeString(parsed.searchParams.get('state'));
|
||||
if ((!code && !errorText) || !authState) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hostLooksLocal = ['localhost', '127.0.0.1'].includes(parsed.hostname);
|
||||
const pathLooksLikeCallback = /callback/i.test(parsed.pathname || '');
|
||||
if (!hostLooksLocal && !pathLooksLikeCallback) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedState = normalizeString(state.contributionAuthState);
|
||||
return !expectedState || expectedState === authState;
|
||||
}
|
||||
|
||||
async function openContributionAuthUrl(authUrl, options = {}) {
|
||||
const normalizedUrl = normalizeString(authUrl);
|
||||
if (!normalizedUrl) {
|
||||
throw new Error('贡献服务未返回有效的登录地址。');
|
||||
}
|
||||
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const preferredTabId = normalizePositiveInteger(options.tabId || currentState.contributionAuthTabId, 0);
|
||||
let tab = null;
|
||||
|
||||
if (preferredTabId) {
|
||||
tab = await chrome.tabs.update(preferredTabId, {
|
||||
url: normalizedUrl,
|
||||
active: true,
|
||||
}).catch(() => null);
|
||||
}
|
||||
|
||||
if (!tab) {
|
||||
tab = await chrome.tabs.create({ url: normalizedUrl, active: true });
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionAuthUrl: normalizedUrl,
|
||||
contributionAuthOpenedAt: Date.now(),
|
||||
contributionAuthTabId: normalizePositiveInteger(tab?.id, 0),
|
||||
});
|
||||
|
||||
return tab;
|
||||
}
|
||||
|
||||
async function fetchContributionResult(sessionId) {
|
||||
try {
|
||||
return await fetchContributionJson(`/result?session_id=${encodeURIComponent(sessionId)}`);
|
||||
} catch (error) {
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:获取最终结果失败:${error.message}`, 'warn');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitContributionCallback(callbackUrl, options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const sessionId = normalizeString(currentState.contributionSessionId);
|
||||
const normalizedUrl = normalizeString(callbackUrl);
|
||||
|
||||
if (!sessionId || !normalizedUrl) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting') {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'submitting',
|
||||
contributionCallbackMessage: buildCallbackMessage('submitting'),
|
||||
});
|
||||
|
||||
try {
|
||||
const payload = await fetchContributionJson('/submit-callback', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
session_id: sessionId,
|
||||
callback_url: normalizedUrl,
|
||||
},
|
||||
});
|
||||
|
||||
const nextStatus = 'submitted';
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: nextStatus,
|
||||
contributionCallbackMessage: buildCallbackMessage(nextStatus, payload),
|
||||
});
|
||||
|
||||
if (typeof closeLocalhostCallbackTabs === 'function') {
|
||||
await closeLocalhostCallbackTabs(normalizedUrl).catch(() => {});
|
||||
}
|
||||
|
||||
return await pollContributionStatus({ reason: options.reason || 'submit_callback' });
|
||||
} catch (error) {
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'failed',
|
||||
contributionCallbackMessage: `回调提交失败:${error.message}`,
|
||||
});
|
||||
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:回调提交失败:${error.message}`, 'warn');
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCapturedCallback(rawUrl, metadata = {}) {
|
||||
const currentState = await getState();
|
||||
if (!normalizeString(currentState.contributionSessionId) || !currentState.contributionMode) {
|
||||
return currentState;
|
||||
}
|
||||
if (!isContributionCallbackUrl(rawUrl, currentState)) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const normalizedUrl = normalizeString(rawUrl);
|
||||
const currentCallbackStatus = normalizeContributionCallbackStatus(currentState.contributionCallbackStatus);
|
||||
if (
|
||||
normalizedUrl
|
||||
&& normalizeString(currentState.contributionCallbackUrl) === normalizedUrl
|
||||
&& (CALLBACK_FINAL_STATUSES.has(currentCallbackStatus) || currentCallbackStatus === 'submitting')
|
||||
) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionCallbackUrl: normalizedUrl,
|
||||
contributionCallbackStatus: 'captured',
|
||||
contributionCallbackMessage: buildCallbackMessage('captured'),
|
||||
});
|
||||
|
||||
if (typeof addLog === 'function') {
|
||||
await addLog(`贡献模式:已捕获回调地址(${metadata.source || 'unknown'})。`, 'info');
|
||||
}
|
||||
|
||||
try {
|
||||
return await submitContributionCallback(normalizedUrl, {
|
||||
reason: metadata.source || 'navigation',
|
||||
stateOverride: await getState(),
|
||||
});
|
||||
} catch {
|
||||
return getState();
|
||||
}
|
||||
}
|
||||
|
||||
async function pollContributionStatus(options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const sessionId = normalizeString(currentState.contributionSessionId);
|
||||
if (!sessionId) {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
const payload = await fetchContributionJson(`/status?session_id=${encodeURIComponent(sessionId)}`);
|
||||
const nextStatus = normalizeContributionStatus(payload.status || payload.state || payload.phase) || currentState.contributionStatus || 'waiting';
|
||||
let finalPayload = null;
|
||||
|
||||
if (isContributionFinalStatus(nextStatus)) {
|
||||
finalPayload = await fetchContributionResult(sessionId);
|
||||
}
|
||||
|
||||
const mergedPayload = finalPayload ? { ...payload, ...finalPayload } : payload;
|
||||
const normalizedStatus = normalizeContributionStatus(mergedPayload.status || mergedPayload.state || mergedPayload.phase) || nextStatus;
|
||||
const callbackState = deriveCallbackState(mergedPayload, currentState);
|
||||
const updates = {
|
||||
contributionLastPollAt: Date.now(),
|
||||
contributionStatus: normalizedStatus,
|
||||
contributionStatusMessage: buildStatusMessage(normalizedStatus, mergedPayload),
|
||||
contributionCallbackUrl: callbackState.callbackUrl,
|
||||
contributionCallbackStatus: callbackState.status,
|
||||
contributionCallbackMessage: callbackState.message,
|
||||
};
|
||||
|
||||
const authUrl = normalizeString(mergedPayload.auth_url || mergedPayload.authUrl);
|
||||
if (authUrl) {
|
||||
updates.contributionAuthUrl = authUrl;
|
||||
}
|
||||
|
||||
const authState = normalizeString(mergedPayload.state || mergedPayload.auth_state || mergedPayload.authState)
|
||||
|| (authUrl ? extractAuthStateFromUrl(authUrl) : '');
|
||||
if (authState) {
|
||||
updates.contributionAuthState = authState;
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates(updates);
|
||||
const nextState = await getState();
|
||||
|
||||
if (
|
||||
normalizeString(nextState.contributionCallbackUrl)
|
||||
&& CALLBACK_WAITING_STATUSES.has(normalizeContributionCallbackStatus(nextState.contributionCallbackStatus))
|
||||
) {
|
||||
try {
|
||||
return await submitContributionCallback(nextState.contributionCallbackUrl, {
|
||||
reason: options.reason || 'status_poll',
|
||||
stateOverride: nextState,
|
||||
});
|
||||
} catch {
|
||||
return getState();
|
||||
}
|
||||
}
|
||||
|
||||
return nextState;
|
||||
}
|
||||
|
||||
async function startContributionFlow(options = {}) {
|
||||
const currentState = options.stateOverride || await getState();
|
||||
const shouldOpenAuthTab = options.openAuthTab !== false;
|
||||
if (!currentState.contributionMode) {
|
||||
throw new Error('请先进入贡献模式。');
|
||||
}
|
||||
|
||||
const currentSessionId = normalizeString(currentState.contributionSessionId);
|
||||
const currentStatus = normalizeContributionStatus(currentState.contributionStatus);
|
||||
if (currentSessionId && ACTIVE_STATUSES.has(currentStatus)) {
|
||||
if (normalizeString(currentState.contributionAuthUrl)) {
|
||||
if (shouldOpenAuthTab) {
|
||||
await openContributionAuthUrl(currentState.contributionAuthUrl, {
|
||||
stateOverride: currentState,
|
||||
}).catch(() => null);
|
||||
}
|
||||
}
|
||||
return pollContributionStatus({ reason: 'resume_existing' });
|
||||
}
|
||||
|
||||
const payload = await fetchContributionJson('/start', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
nickname: buildNickname(currentState, options.nickname),
|
||||
qq: buildContributionQq(currentState, options.qq),
|
||||
email: normalizeString(currentState.email),
|
||||
source: 'cpa',
|
||||
channel: 'codex-extension',
|
||||
},
|
||||
});
|
||||
|
||||
const sessionId = normalizeString(payload.session_id || payload.sessionId);
|
||||
const authUrl = normalizeString(payload.auth_url || payload.authUrl);
|
||||
const authState = normalizeString(payload.state || payload.auth_state || payload.authState) || extractAuthStateFromUrl(authUrl);
|
||||
if (!sessionId || !authUrl) {
|
||||
throw new Error('贡献服务未返回有效的 session_id 或 auth_url。');
|
||||
}
|
||||
|
||||
await applyRuntimeUpdates({
|
||||
contributionSessionId: sessionId,
|
||||
contributionAuthUrl: authUrl,
|
||||
contributionAuthState: authState,
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: normalizeContributionStatus(payload.status) || 'started',
|
||||
contributionStatusMessage: buildStatusMessage(normalizeContributionStatus(payload.status) || 'started', payload),
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: buildCallbackMessage('waiting'),
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
});
|
||||
|
||||
if (shouldOpenAuthTab) {
|
||||
await openContributionAuthUrl(authUrl);
|
||||
}
|
||||
return pollContributionStatus({ reason: 'after_start' });
|
||||
}
|
||||
|
||||
function onNavigationEvent(details = {}, source) {
|
||||
if (details?.frameId !== undefined && Number(details.frameId) !== 0) {
|
||||
return;
|
||||
}
|
||||
handleCapturedCallback(details?.url || '', {
|
||||
source,
|
||||
tabId: normalizePositiveInteger(details?.tabId, 0),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function onTabUpdated(tabId, changeInfo, tab) {
|
||||
const candidateUrl = normalizeString(changeInfo?.url || tab?.url);
|
||||
if (!candidateUrl) {
|
||||
return;
|
||||
}
|
||||
handleCapturedCallback(candidateUrl, {
|
||||
source: 'tabs.onUpdated',
|
||||
tabId: normalizePositiveInteger(tabId, 0),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
function ensureCallbackListeners() {
|
||||
if (listenersBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
chrome.webNavigation.onCommitted.addListener((details) => {
|
||||
onNavigationEvent(details, 'webNavigation.onCommitted');
|
||||
});
|
||||
chrome.webNavigation.onHistoryStateUpdated.addListener((details) => {
|
||||
onNavigationEvent(details, 'webNavigation.onHistoryStateUpdated');
|
||||
});
|
||||
chrome.tabs.onUpdated.addListener(onTabUpdated);
|
||||
listenersBound = true;
|
||||
}
|
||||
|
||||
return {
|
||||
ensureCallbackListeners,
|
||||
handleCapturedCallback,
|
||||
isContributionCallbackUrl,
|
||||
isContributionFinalStatus,
|
||||
pollContributionStatus,
|
||||
startContributionFlow,
|
||||
submitContributionCallback,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ACTIVE_STATUSES,
|
||||
FINAL_STATUSES,
|
||||
RUNTIME_DEFAULTS,
|
||||
RUNTIME_KEYS,
|
||||
createContributionOAuthManager,
|
||||
};
|
||||
});
|
||||
@@ -13,6 +13,7 @@
|
||||
getCloudflareTempEmailAddressFromResponse,
|
||||
getCloudflareTempEmailConfig,
|
||||
getState,
|
||||
ensureMail2925AccountForFlow,
|
||||
joinCloudflareTempEmailUrl,
|
||||
normalizeCloudflareDomain,
|
||||
normalizeCloudflareTempEmailAddress,
|
||||
@@ -145,6 +146,7 @@
|
||||
const requestedName = String(options.localPart || options.name || '').trim().toLowerCase() || generateCloudflareAliasLocalPart();
|
||||
const payload = {
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: Boolean(config.useRandomSubdomain),
|
||||
name: requestedName,
|
||||
domain: config.domain,
|
||||
};
|
||||
@@ -190,16 +192,35 @@
|
||||
async function fetchManagedAliasEmail(state, options = {}) {
|
||||
throwIfStopped();
|
||||
const provider = String(options.mailProvider || state?.mailProvider || '').trim().toLowerCase();
|
||||
const mergedState = {
|
||||
let mergedState = {
|
||||
...(state || {}),
|
||||
mailProvider: provider,
|
||||
};
|
||||
if (options.mail2925Mode !== undefined) {
|
||||
mergedState.mail2925Mode = String(options.mail2925Mode || '').trim();
|
||||
}
|
||||
if (options.gmailBaseEmail !== undefined) {
|
||||
mergedState.gmailBaseEmail = String(options.gmailBaseEmail || '').trim();
|
||||
}
|
||||
if (options.mail2925BaseEmail !== undefined) {
|
||||
mergedState.mail2925BaseEmail = String(options.mail2925BaseEmail || '').trim();
|
||||
}
|
||||
if (
|
||||
provider === '2925'
|
||||
&& Boolean(mergedState.mail2925UseAccountPool)
|
||||
&& typeof ensureMail2925AccountForFlow === 'function'
|
||||
) {
|
||||
const account = await ensureMail2925AccountForFlow({
|
||||
allowAllocate: true,
|
||||
preferredAccountId: mergedState.currentMail2925AccountId || null,
|
||||
});
|
||||
const latestState = await getState();
|
||||
mergedState = {
|
||||
...latestState,
|
||||
...mergedState,
|
||||
currentMail2925AccountId: account.id,
|
||||
};
|
||||
}
|
||||
|
||||
const email = buildGeneratedAliasEmail(mergedState);
|
||||
await setEmailState(email);
|
||||
@@ -210,7 +231,10 @@
|
||||
async function fetchGeneratedEmail(state, options = {}) {
|
||||
const currentState = state || await getState();
|
||||
const provider = String(options.mailProvider || currentState.mailProvider || '').trim().toLowerCase();
|
||||
if (isGeneratedAliasProvider?.(provider)) {
|
||||
const mail2925Mode = options.mail2925Mode !== undefined
|
||||
? options.mail2925Mode
|
||||
: currentState.mail2925Mode;
|
||||
if (isGeneratedAliasProvider?.(provider, mail2925Mode)) {
|
||||
return fetchManagedAliasEmail(currentState, options);
|
||||
}
|
||||
const generator = normalizeEmailGenerator(options.generator ?? currentState.emailGenerator);
|
||||
|
||||
@@ -91,6 +91,11 @@
|
||||
return /当前邮箱已存在,需要重新开始新一轮/.test(message);
|
||||
}
|
||||
|
||||
function isSignupUserAlreadyExistsFailure(error) {
|
||||
const message = getErrorMessage(error);
|
||||
return /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(message);
|
||||
}
|
||||
|
||||
function isStep9RecoverableAuthError(error) {
|
||||
const message = String(typeof error === 'string' ? error : error?.message || '');
|
||||
return /STEP9_OAUTH_RETRY::/i.test(message)
|
||||
@@ -162,6 +167,7 @@
|
||||
hasSavedProgress,
|
||||
isLegacyStep9RecoverableAuthError,
|
||||
isRestartCurrentAttemptError,
|
||||
isSignupUserAlreadyExistsFailure,
|
||||
isStep9RecoverableAuthError,
|
||||
isStepDoneStatus,
|
||||
isVerificationMailPollingError,
|
||||
|
||||
@@ -0,0 +1,773 @@
|
||||
(function attachBackgroundMail2925Session(root, factory) {
|
||||
root.MultiPageBackgroundMail2925Session = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundMail2925SessionModule() {
|
||||
function createMail2925SessionManager(deps = {}) {
|
||||
const {
|
||||
addLog,
|
||||
broadcastDataUpdate,
|
||||
chrome,
|
||||
ensureContentScriptReadyOnTab,
|
||||
findMail2925Account,
|
||||
getMail2925AccountStatus,
|
||||
getState,
|
||||
isAutoRunLockedState,
|
||||
isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account,
|
||||
normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun,
|
||||
requestStop,
|
||||
reuseOrCreateTab,
|
||||
sendToContentScriptResilient,
|
||||
sendToMailContentScriptResilient,
|
||||
setPersistentSettings,
|
||||
setState,
|
||||
sleepWithStop,
|
||||
throwIfStopped,
|
||||
upsertMail2925AccountInList,
|
||||
waitForTabComplete,
|
||||
waitForTabUrlMatch,
|
||||
} = deps;
|
||||
|
||||
const MAIL2925_SOURCE = 'mail-2925';
|
||||
const MAIL2925_URL = 'https://2925.com/#/mailList';
|
||||
const MAIL2925_LOGIN_URL = 'https://2925.com/login/';
|
||||
const MAIL2925_INJECT = ['content/utils.js', 'content/mail-2925.js'];
|
||||
const MAIL2925_INJECT_SOURCE = 'mail-2925';
|
||||
const MAIL2925_COOKIE_DOMAINS = [
|
||||
'2925.com',
|
||||
'www.2925.com',
|
||||
'mail2.xiyouji.com',
|
||||
];
|
||||
const MAIL2925_COOKIE_ORIGINS = [
|
||||
'https://2925.com',
|
||||
'https://www.2925.com',
|
||||
'https://mail2.xiyouji.com',
|
||||
];
|
||||
const MAIL2925_LIMIT_ERROR_PREFIX = 'MAIL2925_LIMIT_REACHED::';
|
||||
const MAIL2925_THREAD_TERMINATED_ERROR_PREFIX = 'MAIL2925_THREAD_TERMINATED::';
|
||||
const MAIL2925_LOGIN_MESSAGE_RETRY_WINDOW_MS = 15000;
|
||||
const MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS = 120000;
|
||||
const MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS = 120000;
|
||||
|
||||
function getMail2925MailConfig() {
|
||||
return {
|
||||
provider: '2925',
|
||||
source: MAIL2925_SOURCE,
|
||||
url: MAIL2925_URL,
|
||||
label: '2925 邮箱',
|
||||
inject: MAIL2925_INJECT,
|
||||
injectSource: MAIL2925_INJECT_SOURCE,
|
||||
};
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
return String(typeof error === 'string' ? error : error?.message || '');
|
||||
}
|
||||
|
||||
function buildMail2925ThreadTerminatedError(message) {
|
||||
return new Error(`${MAIL2925_THREAD_TERMINATED_ERROR_PREFIX}${String(message || '').trim()}`);
|
||||
}
|
||||
|
||||
async function stopAutoRunForMail2925LoginFailure(errorMessage = '') {
|
||||
if (typeof requestStop !== 'function') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const state = await getState();
|
||||
const autoRunning = typeof isAutoRunLockedState === 'function'
|
||||
? isAutoRunLockedState(state)
|
||||
: Boolean(state?.autoRunning);
|
||||
if (!autoRunning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
await requestStop({
|
||||
logMessage: errorMessage || '2925 登录失败,已按手动停止逻辑暂停自动流程。',
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function isMail2925LimitReachedError(error) {
|
||||
const message = getErrorMessage(error);
|
||||
return message.startsWith(MAIL2925_LIMIT_ERROR_PREFIX)
|
||||
|| message.includes('子邮箱已达上限')
|
||||
|| message.includes('已达上限邮箱');
|
||||
}
|
||||
|
||||
function isMail2925ThreadTerminatedError(error) {
|
||||
return getErrorMessage(error).startsWith(MAIL2925_THREAD_TERMINATED_ERROR_PREFIX);
|
||||
}
|
||||
|
||||
function isRetryableMail2925TransportError(error) {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
return message.includes('receiving end does not exist')
|
||||
|| message.includes('message port closed')
|
||||
|| message.includes('content script on')
|
||||
|| message.includes('did not respond');
|
||||
}
|
||||
|
||||
async function syncMail2925Accounts(accounts) {
|
||||
const normalized = normalizeMail2925Accounts(accounts);
|
||||
await setPersistentSettings({ mail2925Accounts: normalized });
|
||||
await setState({ mail2925Accounts: normalized });
|
||||
broadcastDataUpdate({ mail2925Accounts: normalized });
|
||||
return normalized;
|
||||
}
|
||||
|
||||
async function upsertMail2925Account(input = {}) {
|
||||
const state = await getState();
|
||||
const accounts = normalizeMail2925Accounts(state.mail2925Accounts);
|
||||
const normalizedEmail = String(input?.email || '').trim().toLowerCase();
|
||||
const existing = input?.id
|
||||
? findMail2925Account(accounts, input.id)
|
||||
: accounts.find((account) => account.email === normalizedEmail) || null;
|
||||
const credentialsChanged = !existing
|
||||
|| (input?.email !== undefined && normalizedEmail !== existing.email)
|
||||
|| (input?.password !== undefined && String(input.password || '') !== existing.password);
|
||||
const normalized = normalizeMail2925Account({
|
||||
...(existing || {}),
|
||||
...(credentialsChanged ? { lastError: '' } : {}),
|
||||
...input,
|
||||
id: input?.id || existing?.id || crypto.randomUUID(),
|
||||
});
|
||||
|
||||
const nextAccounts = existing
|
||||
? accounts.map((account) => (account.id === normalized.id ? normalized : account))
|
||||
: [...accounts, normalized];
|
||||
|
||||
await syncMail2925Accounts(nextAccounts);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getCurrentMail2925Account(state = {}) {
|
||||
return findMail2925Account(state.mail2925Accounts, state.currentMail2925AccountId) || null;
|
||||
}
|
||||
|
||||
async function getMail2925CurrentTabUrl() {
|
||||
try {
|
||||
const state = await getState();
|
||||
const tabId = Number(state?.tabRegistry?.[MAIL2925_SOURCE]?.tabId || 0);
|
||||
if (!Number.isInteger(tabId) || tabId <= 0 || typeof chrome.tabs?.get !== 'function') {
|
||||
return '';
|
||||
}
|
||||
const tab = await chrome.tabs.get(tabId);
|
||||
return String(tab?.url || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function getMail2925TabUrlById(tabId) {
|
||||
try {
|
||||
if (!Number.isInteger(Number(tabId)) || Number(tabId) <= 0 || typeof chrome.tabs?.get !== 'function') {
|
||||
return '';
|
||||
}
|
||||
const tab = await chrome.tabs.get(Number(tabId));
|
||||
return String(tab?.url || '').trim();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isMail2925LoginUrl(rawUrl = '') {
|
||||
try {
|
||||
const parsed = new URL(String(rawUrl || ''));
|
||||
return (parsed.hostname === '2925.com' || parsed.hostname === 'www.2925.com')
|
||||
&& /^\/login\/?$/.test(parsed.pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMailboxEmail(value = '') {
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function setCurrentMail2925Account(accountId, options = {}) {
|
||||
const { logMessage = '', updateLastUsedAt = false } = options;
|
||||
const state = await getState();
|
||||
const accounts = normalizeMail2925Accounts(state.mail2925Accounts);
|
||||
const account = findMail2925Account(accounts, accountId);
|
||||
if (!account) {
|
||||
throw new Error('未找到对应的 2925 账号。');
|
||||
}
|
||||
|
||||
let nextAccount = account;
|
||||
if (updateLastUsedAt) {
|
||||
nextAccount = normalizeMail2925Account({
|
||||
...account,
|
||||
lastUsedAt: Date.now(),
|
||||
});
|
||||
await syncMail2925Accounts(accounts.map((item) => (item.id === account.id ? nextAccount : item)));
|
||||
}
|
||||
|
||||
await setPersistentSettings({ currentMail2925AccountId: nextAccount.id });
|
||||
await setState({ currentMail2925AccountId: nextAccount.id });
|
||||
broadcastDataUpdate({ currentMail2925AccountId: nextAccount.id });
|
||||
if (logMessage) {
|
||||
await addLog(logMessage, 'ok');
|
||||
}
|
||||
return nextAccount;
|
||||
}
|
||||
|
||||
async function patchMail2925Account(accountId, updates = {}) {
|
||||
const state = await getState();
|
||||
const accounts = normalizeMail2925Accounts(state.mail2925Accounts);
|
||||
const account = findMail2925Account(accounts, accountId);
|
||||
if (!account) {
|
||||
throw new Error('未找到对应的 2925 账号。');
|
||||
}
|
||||
|
||||
const nextAccount = normalizeMail2925Account({
|
||||
...account,
|
||||
...updates,
|
||||
id: account.id,
|
||||
});
|
||||
await syncMail2925Accounts(accounts.map((item) => (item.id === account.id ? nextAccount : item)));
|
||||
|
||||
if (state.currentMail2925AccountId === account.id && nextAccount.enabled === false) {
|
||||
await setPersistentSettings({ currentMail2925AccountId: '' });
|
||||
await setState({ currentMail2925AccountId: null });
|
||||
broadcastDataUpdate({ currentMail2925AccountId: null });
|
||||
}
|
||||
|
||||
return nextAccount;
|
||||
}
|
||||
|
||||
async function deleteMail2925Account(accountId) {
|
||||
const state = await getState();
|
||||
const accounts = normalizeMail2925Accounts(state.mail2925Accounts);
|
||||
const nextAccounts = accounts.filter((account) => account.id !== accountId);
|
||||
await syncMail2925Accounts(nextAccounts);
|
||||
|
||||
if (state.currentMail2925AccountId === accountId) {
|
||||
await setPersistentSettings({ currentMail2925AccountId: '' });
|
||||
await setState({ currentMail2925AccountId: null });
|
||||
broadcastDataUpdate({ currentMail2925AccountId: null });
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteMail2925Accounts(mode = 'all') {
|
||||
const state = await getState();
|
||||
const accounts = normalizeMail2925Accounts(state.mail2925Accounts);
|
||||
const nextAccounts = mode === 'all'
|
||||
? []
|
||||
: accounts.filter((account) => getMail2925AccountStatus(account) !== String(mode || '').trim());
|
||||
const deletedCount = Math.max(0, accounts.length - nextAccounts.length);
|
||||
await syncMail2925Accounts(nextAccounts);
|
||||
|
||||
if (state.currentMail2925AccountId && !findMail2925Account(nextAccounts, state.currentMail2925AccountId)) {
|
||||
await setPersistentSettings({ currentMail2925AccountId: '' });
|
||||
await setState({ currentMail2925AccountId: null });
|
||||
broadcastDataUpdate({ currentMail2925AccountId: null });
|
||||
}
|
||||
|
||||
return {
|
||||
deletedCount,
|
||||
remainingCount: nextAccounts.length,
|
||||
};
|
||||
}
|
||||
|
||||
async function ensureMail2925AccountForFlow(options = {}) {
|
||||
const {
|
||||
allowAllocate = true,
|
||||
preferredAccountId = null,
|
||||
excludeIds = [],
|
||||
markUsed = false,
|
||||
} = options;
|
||||
const state = await getState();
|
||||
const accounts = normalizeMail2925Accounts(state.mail2925Accounts);
|
||||
const now = Date.now();
|
||||
|
||||
let account = null;
|
||||
if (preferredAccountId) {
|
||||
account = findMail2925Account(accounts, preferredAccountId);
|
||||
}
|
||||
if (!account && state.currentMail2925AccountId) {
|
||||
account = findMail2925Account(accounts, state.currentMail2925AccountId);
|
||||
}
|
||||
if ((!account || !isMail2925AccountAvailable(account, now)) && allowAllocate) {
|
||||
account = pickMail2925AccountForRun(accounts, {
|
||||
excludeIds,
|
||||
now,
|
||||
});
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
throw new Error('没有可用的 2925 账号。请先在侧边栏添加至少一个带密码的 2925 账号。');
|
||||
}
|
||||
if (!account.password) {
|
||||
throw new Error(`2925 账号 ${account.email || account.id} 缺少密码,无法自动登录。`);
|
||||
}
|
||||
if (!isMail2925AccountAvailable(account, now)) {
|
||||
const disabledUntil = Number(account.disabledUntil || 0);
|
||||
if (disabledUntil > now) {
|
||||
throw new Error(`2925 账号 ${account.email || account.id} 当前处于冷却期,将在 ${new Date(disabledUntil).toLocaleString('zh-CN', { hour12: false })} 后恢复。`);
|
||||
}
|
||||
throw new Error(`2925 账号 ${account.email || account.id} 当前不可用。`);
|
||||
}
|
||||
|
||||
return setCurrentMail2925Account(account.id, { updateLastUsedAt: markUsed });
|
||||
}
|
||||
|
||||
function normalizeCookieDomainForMatch(domain) {
|
||||
return String(domain || '').trim().replace(/^\.+/, '').toLowerCase();
|
||||
}
|
||||
|
||||
function shouldClearMail2925Cookie(cookie) {
|
||||
const domain = normalizeCookieDomainForMatch(cookie?.domain);
|
||||
if (!domain) return false;
|
||||
return MAIL2925_COOKIE_DOMAINS.some((target) => (
|
||||
domain === target || domain.endsWith(`.${target}`)
|
||||
));
|
||||
}
|
||||
|
||||
function buildCookieRemovalUrl(cookie) {
|
||||
const host = normalizeCookieDomainForMatch(cookie?.domain);
|
||||
const path = String(cookie?.path || '/').startsWith('/')
|
||||
? String(cookie?.path || '/')
|
||||
: `/${String(cookie?.path || '')}`;
|
||||
return `https://${host}${path}`;
|
||||
}
|
||||
|
||||
async function collectMail2925Cookies() {
|
||||
if (!chrome.cookies?.getAll) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const stores = chrome.cookies.getAllCookieStores
|
||||
? await chrome.cookies.getAllCookieStores()
|
||||
: [{ id: undefined }];
|
||||
const cookies = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const store of stores) {
|
||||
const storeId = store?.id;
|
||||
const batch = await chrome.cookies.getAll(storeId ? { storeId } : {});
|
||||
for (const cookie of batch || []) {
|
||||
if (!shouldClearMail2925Cookie(cookie)) continue;
|
||||
const key = [
|
||||
cookie.storeId || storeId || '',
|
||||
cookie.domain || '',
|
||||
cookie.path || '',
|
||||
cookie.name || '',
|
||||
cookie.partitionKey ? JSON.stringify(cookie.partitionKey) : '',
|
||||
].join('|');
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
cookies.push(cookie);
|
||||
}
|
||||
}
|
||||
|
||||
return cookies;
|
||||
}
|
||||
|
||||
async function removeMail2925Cookie(cookie) {
|
||||
const details = {
|
||||
url: buildCookieRemovalUrl(cookie),
|
||||
name: cookie.name,
|
||||
};
|
||||
|
||||
if (cookie.storeId) {
|
||||
details.storeId = cookie.storeId;
|
||||
}
|
||||
if (cookie.partitionKey) {
|
||||
details.partitionKey = cookie.partitionKey;
|
||||
}
|
||||
|
||||
try {
|
||||
return Boolean(await chrome.cookies.remove(details));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function clearMail2925SessionCookies() {
|
||||
if (!chrome.cookies?.getAll || !chrome.cookies?.remove) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cookies = await collectMail2925Cookies();
|
||||
let removedCount = 0;
|
||||
for (const cookie of cookies) {
|
||||
throwIfStopped();
|
||||
if (await removeMail2925Cookie(cookie)) {
|
||||
removedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (chrome.browsingData?.removeCookies) {
|
||||
try {
|
||||
await chrome.browsingData.removeCookies({
|
||||
since: 0,
|
||||
origins: MAIL2925_COOKIE_ORIGINS,
|
||||
});
|
||||
} catch (_) {
|
||||
// Best effort cleanup only.
|
||||
}
|
||||
}
|
||||
|
||||
return removedCount;
|
||||
}
|
||||
|
||||
async function recoverMail2925LoginPageAfterTransportError(tabId) {
|
||||
const numericTabId = Number(tabId);
|
||||
if (!Number.isInteger(numericTabId) || numericTabId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentUrl = (await getMail2925TabUrlById(numericTabId)) || await getMail2925CurrentTabUrl();
|
||||
await addLog(
|
||||
`2925:登录提交后页面发生跳转或重载,正在等待当前标签页恢复后继续确认登录态。当前地址:${currentUrl || 'unknown'}`,
|
||||
'warn'
|
||||
);
|
||||
|
||||
if (typeof waitForTabComplete === 'function') {
|
||||
const completedTab = await waitForTabComplete(numericTabId, {
|
||||
timeoutMs: MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS,
|
||||
retryDelayMs: 300,
|
||||
});
|
||||
await addLog(
|
||||
`2925:登录跳转等待结束,当前标签地址:${String(completedTab?.url || '').trim() || 'unknown'}`,
|
||||
completedTab?.url ? 'info' : 'warn'
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof ensureContentScriptReadyOnTab === 'function') {
|
||||
await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, numericTabId, {
|
||||
inject: MAIL2925_INJECT,
|
||||
injectSource: MAIL2925_INJECT_SOURCE,
|
||||
timeoutMs: MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS,
|
||||
retryDelayMs: 800,
|
||||
logMessage: '步骤 0:2925 登录后页面仍在跳转,正在等待邮箱页重新就绪...',
|
||||
});
|
||||
}
|
||||
|
||||
const recoveredUrl = (await getMail2925TabUrlById(numericTabId)) || await getMail2925CurrentTabUrl();
|
||||
await addLog(`2925:登录跳转恢复后当前标签地址:${recoveredUrl || 'unknown'}`, 'info');
|
||||
}
|
||||
|
||||
async function ensureMail2925MailboxSession(options = {}) {
|
||||
const {
|
||||
accountId = null,
|
||||
forceRelogin = false,
|
||||
actionLabel = '确保 2925 邮箱登录态',
|
||||
allowLoginWhenOnLoginPage = true,
|
||||
expectedMailboxEmail = '',
|
||||
} = options;
|
||||
|
||||
const normalizedExpectedMailboxEmail = normalizeMailboxEmail(expectedMailboxEmail);
|
||||
|
||||
let account = null;
|
||||
if (forceRelogin || (allowLoginWhenOnLoginPage && normalizedExpectedMailboxEmail)) {
|
||||
account = await ensureMail2925AccountForFlow({
|
||||
allowAllocate: true,
|
||||
preferredAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const sendLoginMessage = typeof sendToContentScriptResilient === 'function'
|
||||
? sendToContentScriptResilient
|
||||
: async (source, message, runtimeOptions = {}) => sendToMailContentScriptResilient(
|
||||
getMail2925MailConfig(),
|
||||
message,
|
||||
{
|
||||
timeoutMs: runtimeOptions.timeoutMs,
|
||||
responseTimeoutMs: runtimeOptions.responseTimeoutMs,
|
||||
maxRecoveryAttempts: 0,
|
||||
}
|
||||
);
|
||||
|
||||
const buildSuccessPayload = () => ({
|
||||
account,
|
||||
mail: getMail2925MailConfig(),
|
||||
result: {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
usedExistingSession: true,
|
||||
},
|
||||
});
|
||||
|
||||
const failMailboxSession = async (message) => {
|
||||
const stopped = await stopAutoRunForMail2925LoginFailure(`${message}已按手动停止逻辑暂停自动流程。`);
|
||||
if (stopped) {
|
||||
throw new Error('流程已被用户停止。');
|
||||
}
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
if (forceRelogin) {
|
||||
const removedCount = await clearMail2925SessionCookies();
|
||||
await addLog(`2925:已清理 ${removedCount} 个登录相关 cookie,准备使用 ${account.email} 重新登录。`, 'info');
|
||||
if (typeof sleepWithStop === 'function') {
|
||||
await addLog('2925:清理 cookie 后等待 3 秒,再打开登录页...', 'info');
|
||||
await sleepWithStop(3000);
|
||||
}
|
||||
}
|
||||
|
||||
throwIfStopped();
|
||||
const targetUrl = forceRelogin ? MAIL2925_LOGIN_URL : MAIL2925_URL;
|
||||
await addLog(
|
||||
forceRelogin
|
||||
? `2925:准备打开登录页 ${MAIL2925_LOGIN_URL}(强制重登录)`
|
||||
: `2925:准备打开邮箱页 ${MAIL2925_URL}(登录页自动登录=${allowLoginWhenOnLoginPage ? '开启' : '关闭'})`,
|
||||
'info'
|
||||
);
|
||||
const tabId = await reuseOrCreateTab(MAIL2925_SOURCE, targetUrl, {
|
||||
inject: MAIL2925_INJECT,
|
||||
injectSource: MAIL2925_INJECT_SOURCE,
|
||||
});
|
||||
|
||||
let openedUrl = await getMail2925TabUrlById(tabId);
|
||||
if (!openedUrl) {
|
||||
openedUrl = await getMail2925CurrentTabUrl();
|
||||
}
|
||||
await addLog(`2925:打开页后当前标签地址:${openedUrl || 'unknown'}`, 'info');
|
||||
|
||||
if (forceRelogin && typeof waitForTabUrlMatch === 'function') {
|
||||
const matchedLoginTab = await waitForTabUrlMatch(
|
||||
tabId,
|
||||
(url) => isMail2925LoginUrl(url),
|
||||
{ timeoutMs: 15000, retryDelayMs: 300 }
|
||||
);
|
||||
await addLog(`2925:等待最终落到登录页结果:${matchedLoginTab?.url || '超时'}`, matchedLoginTab ? 'info' : 'warn');
|
||||
if (matchedLoginTab?.url) {
|
||||
openedUrl = String(matchedLoginTab.url || '').trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (!forceRelogin && !isMail2925LoginUrl(openedUrl) && !normalizedExpectedMailboxEmail) {
|
||||
await addLog('2925:当前邮箱页未跳转到登录页,将直接复用已登录会话。', 'info');
|
||||
return buildSuccessPayload();
|
||||
}
|
||||
|
||||
if (!forceRelogin && isMail2925LoginUrl(openedUrl) && !allowLoginWhenOnLoginPage) {
|
||||
await failMailboxSession(`2925:${actionLabel}失败,当前页面已跳转到登录页,且当前未启用 2925 账号池,不执行自动登录。`);
|
||||
}
|
||||
|
||||
if (!account && (forceRelogin || allowLoginWhenOnLoginPage)) {
|
||||
account = await ensureMail2925AccountForFlow({
|
||||
allowAllocate: true,
|
||||
preferredAccountId: accountId,
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof ensureContentScriptReadyOnTab === 'function') {
|
||||
await ensureContentScriptReadyOnTab(MAIL2925_SOURCE, tabId, {
|
||||
inject: MAIL2925_INJECT,
|
||||
injectSource: MAIL2925_INJECT_SOURCE,
|
||||
timeoutMs: 20000,
|
||||
retryDelayMs: 800,
|
||||
logMessage: '步骤 0:2925 登录页内容脚本未就绪,正在等待页面稳定后继续登录...',
|
||||
});
|
||||
}
|
||||
|
||||
if (forceRelogin && typeof sleepWithStop === 'function') {
|
||||
await addLog('2925:登录页已打开,等待 3 秒后开始检查输入框并执行登录...', 'info');
|
||||
await sleepWithStop(3000);
|
||||
}
|
||||
|
||||
let result;
|
||||
const sendEnsureSessionRequest = async () => {
|
||||
const beforeSendUrl = (await getMail2925TabUrlById(tabId)) || await getMail2925CurrentTabUrl();
|
||||
await addLog(`2925:发送 ENSURE_MAIL2925_SESSION 前当前地址:${beforeSendUrl || 'unknown'}`, 'info');
|
||||
return sendLoginMessage(
|
||||
MAIL2925_SOURCE,
|
||||
{
|
||||
type: 'ENSURE_MAIL2925_SESSION',
|
||||
step: 0,
|
||||
source: 'background',
|
||||
payload: {
|
||||
email: account?.email || '',
|
||||
password: account?.password || '',
|
||||
forceLogin: forceRelogin,
|
||||
allowLoginWhenOnLoginPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
timeoutMs: MAIL2925_LOGIN_MESSAGE_RETRY_WINDOW_MS,
|
||||
retryDelayMs: 800,
|
||||
responseTimeoutMs: MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS,
|
||||
logMessage: '步骤 0:2925 登录页通信异常,正在等待页面恢复...',
|
||||
}
|
||||
);
|
||||
};
|
||||
try {
|
||||
result = await sendEnsureSessionRequest();
|
||||
} catch (err) {
|
||||
if (isRetryableMail2925TransportError(err)) {
|
||||
try {
|
||||
await recoverMail2925LoginPageAfterTransportError(tabId);
|
||||
await addLog('2925:页面恢复完成,正在重新确认登录态...', 'info');
|
||||
result = await sendEnsureSessionRequest();
|
||||
} catch (recoveryErr) {
|
||||
err = recoveryErr;
|
||||
}
|
||||
}
|
||||
|
||||
if (!result) {
|
||||
const message = `2925:${actionLabel}失败(${getErrorMessage(err) || '登录结果确认超时'})。`;
|
||||
const stopped = await stopAutoRunForMail2925LoginFailure(`${message}已按手动停止逻辑暂停自动流程。`);
|
||||
if (stopped) {
|
||||
throw new Error('流程已被用户停止。');
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (result?.error) {
|
||||
await failMailboxSession(`2925:${actionLabel}失败(${result.error})。`);
|
||||
}
|
||||
if (result?.limitReached) {
|
||||
throw new Error(`${MAIL2925_LIMIT_ERROR_PREFIX}${result.limitMessage || '子邮箱已达上限邮箱'}`);
|
||||
}
|
||||
const actualMailboxEmail = normalizeMailboxEmail(result?.mailboxEmail || '');
|
||||
if (normalizedExpectedMailboxEmail && actualMailboxEmail && actualMailboxEmail !== normalizedExpectedMailboxEmail) {
|
||||
if (allowLoginWhenOnLoginPage) {
|
||||
await addLog(
|
||||
`2925:当前邮箱页显示账号 ${actualMailboxEmail},与目标账号 ${normalizedExpectedMailboxEmail} 不一致,准备登出当前账号并登录目标账号。`,
|
||||
'warn'
|
||||
);
|
||||
return ensureMail2925MailboxSession({
|
||||
accountId: account?.id || accountId || null,
|
||||
forceRelogin: true,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
expectedMailboxEmail: normalizedExpectedMailboxEmail,
|
||||
actionLabel,
|
||||
});
|
||||
}
|
||||
await failMailboxSession(
|
||||
`2925:${actionLabel}失败,当前邮箱页显示账号 ${actualMailboxEmail},与目标账号 ${normalizedExpectedMailboxEmail} 不一致,且当前未启用 2925 账号池。`
|
||||
);
|
||||
}
|
||||
if (normalizedExpectedMailboxEmail && !actualMailboxEmail && result?.currentView === 'mailbox') {
|
||||
await addLog('2925:未能识别当前邮箱页顶部邮箱地址,已跳过邮箱一致性校验。', 'warn');
|
||||
}
|
||||
if (!result?.loggedIn) {
|
||||
await failMailboxSession(`2925:${actionLabel}失败,登录后仍未进入收件箱。`);
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
await addLog('2925:未触发自动登录,继续复用当前已登录会话。', 'info');
|
||||
return {
|
||||
account: null,
|
||||
mail: getMail2925MailConfig(),
|
||||
result: {
|
||||
...result,
|
||||
usedExistingSession: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
await patchMail2925Account(account.id, {
|
||||
lastLoginAt: Date.now(),
|
||||
lastError: '',
|
||||
});
|
||||
await setState({ currentMail2925AccountId: account.id });
|
||||
broadcastDataUpdate({ currentMail2925AccountId: account.id });
|
||||
|
||||
const finalUrl = (await getMail2925TabUrlById(tabId)) || await getMail2925CurrentTabUrl();
|
||||
await addLog(`2925:登录态确认成功,当前地址=${finalUrl || 'unknown'}`, 'ok');
|
||||
|
||||
return {
|
||||
account: await ensureMail2925AccountForFlow({
|
||||
allowAllocate: false,
|
||||
preferredAccountId: account.id,
|
||||
}),
|
||||
mail: getMail2925MailConfig(),
|
||||
result,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleMail2925LimitReachedError(step, error) {
|
||||
const reason = getErrorMessage(error).replace(MAIL2925_LIMIT_ERROR_PREFIX, '').trim()
|
||||
|| '子邮箱已达上限邮箱';
|
||||
const state = await getState();
|
||||
const currentAccount = getCurrentMail2925Account(state);
|
||||
const poolEnabled = Boolean(state?.mail2925UseAccountPool);
|
||||
|
||||
if (!poolEnabled) {
|
||||
if (typeof requestStop === 'function') {
|
||||
await requestStop({
|
||||
logMessage: `步骤 ${step}:2925 检测到“${reason}”,当前未启用账号池,已按手动停止逻辑暂停自动流程。`,
|
||||
});
|
||||
}
|
||||
return new Error('流程已被用户停止。');
|
||||
}
|
||||
|
||||
if (!currentAccount) {
|
||||
if (typeof requestStop === 'function') {
|
||||
await requestStop({
|
||||
logMessage: `步骤 ${step}:2925 检测到“${reason}”,但当前没有可识别的账号可供切换。`,
|
||||
});
|
||||
}
|
||||
return new Error('流程已被用户停止。');
|
||||
}
|
||||
|
||||
const disabledUntil = Date.now() + Math.max(1, Number(MAIL2925_LIMIT_COOLDOWN_MS) || (24 * 60 * 60 * 1000));
|
||||
await patchMail2925Account(currentAccount.id, {
|
||||
lastLimitAt: Date.now(),
|
||||
disabledUntil,
|
||||
lastError: reason,
|
||||
});
|
||||
await addLog(
|
||||
`步骤 ${step}:2925 账号 ${currentAccount.email} 命中“${reason}”,已禁用到 ${new Date(disabledUntil).toLocaleString('zh-CN', { hour12: false })}。`,
|
||||
'warn'
|
||||
);
|
||||
|
||||
const nextState = await getState();
|
||||
const nextAccounts = normalizeMail2925Accounts(nextState.mail2925Accounts);
|
||||
const nextAccount = pickMail2925AccountForRun(nextAccounts, {
|
||||
excludeIds: [currentAccount.id],
|
||||
});
|
||||
|
||||
if (!nextAccount) {
|
||||
await setPersistentSettings({ currentMail2925AccountId: '' });
|
||||
await setState({ currentMail2925AccountId: null });
|
||||
broadcastDataUpdate({ currentMail2925AccountId: null });
|
||||
if (typeof requestStop === 'function') {
|
||||
await requestStop({
|
||||
logMessage: `步骤 ${step}:2925 账号 ${currentAccount.email} 命中“${reason}”,但当前没有可切换的下一个账号。`,
|
||||
});
|
||||
}
|
||||
return new Error('流程已被用户停止。');
|
||||
}
|
||||
|
||||
await setCurrentMail2925Account(nextAccount.id);
|
||||
await ensureMail2925MailboxSession({
|
||||
accountId: nextAccount.id,
|
||||
forceRelogin: true,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
actionLabel: `步骤 ${step}:切换 2925 账号`,
|
||||
});
|
||||
await addLog(`步骤 ${step}:2925 已切换到下一个账号 ${nextAccount.email}。`, 'warn');
|
||||
return buildMail2925ThreadTerminatedError(
|
||||
`步骤 ${step}:2925 账号 ${currentAccount.email} 命中“${reason}”,已切换到 ${nextAccount.email},当前尝试结束,等待下一轮重试。`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
MAIL2925_LIMIT_ERROR_PREFIX,
|
||||
MAIL2925_THREAD_TERMINATED_ERROR_PREFIX,
|
||||
clearMail2925SessionCookies,
|
||||
deleteMail2925Account,
|
||||
deleteMail2925Accounts,
|
||||
ensureMail2925AccountForFlow,
|
||||
ensureMail2925MailboxSession,
|
||||
getCurrentMail2925Account,
|
||||
getMail2925MailConfig,
|
||||
handleMail2925LimitReachedError,
|
||||
isMail2925LimitReachedError,
|
||||
isMail2925ThreadTerminatedError,
|
||||
patchMail2925Account,
|
||||
setCurrentMail2925Account,
|
||||
syncMail2925Accounts,
|
||||
upsertMail2925Account,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
createMail2925SessionManager,
|
||||
};
|
||||
});
|
||||
@@ -25,6 +25,7 @@
|
||||
deleteUsedIcloudAliases,
|
||||
disableUsedLuckmailPurchases,
|
||||
doesStepUseCompletionSignal,
|
||||
ensureMail2925MailboxSession,
|
||||
ensureManualInteractionAllowed,
|
||||
executeStep,
|
||||
executeStepViaCompletionSignal,
|
||||
@@ -35,9 +36,11 @@
|
||||
findHotmailAccount,
|
||||
flushCommand,
|
||||
getCurrentLuckmailPurchase,
|
||||
getCurrentMail2925Account,
|
||||
getPendingAutoRunTimerPlan,
|
||||
getSourceLabel,
|
||||
getState,
|
||||
getTabId,
|
||||
getStopRequested,
|
||||
handleAutoRunLoopUnhandledError,
|
||||
importSettingsBundle,
|
||||
@@ -48,15 +51,19 @@
|
||||
isLocalhostOAuthCallbackUrl,
|
||||
isLuckmailProvider,
|
||||
isStopError,
|
||||
isTabAlive,
|
||||
launchAutoRunTimerPlan,
|
||||
listIcloudAliases,
|
||||
listLuckmailPurchasesForManagement,
|
||||
normalizeHotmailAccounts,
|
||||
normalizeMail2925Accounts,
|
||||
normalizeRunCount,
|
||||
AUTO_RUN_TIMER_KIND_SCHEDULED_START,
|
||||
notifyStepComplete,
|
||||
notifyStepError,
|
||||
patchMail2925Account,
|
||||
patchHotmailAccount,
|
||||
pollContributionStatus,
|
||||
registerTab,
|
||||
requestStop,
|
||||
handleCloudflareSecurityBlocked,
|
||||
@@ -64,7 +71,9 @@
|
||||
resumeAutoRun,
|
||||
scheduleAutoRun,
|
||||
selectLuckmailPurchase,
|
||||
setCurrentMail2925Account,
|
||||
setCurrentHotmailAccount,
|
||||
setContributionMode,
|
||||
setEmailState,
|
||||
setEmailStateSilently,
|
||||
setIcloudAliasPreservedState,
|
||||
@@ -77,9 +86,13 @@
|
||||
setStepStatus,
|
||||
skipAutoRunCountdown,
|
||||
skipStep,
|
||||
startContributionFlow,
|
||||
startAutoRunLoop,
|
||||
deleteMail2925Account,
|
||||
deleteMail2925Accounts,
|
||||
syncHotmailAccounts,
|
||||
testHotmailAccountMailAccess,
|
||||
upsertMail2925Account,
|
||||
upsertHotmailAccount,
|
||||
verifyHotmailAccount,
|
||||
} = deps;
|
||||
@@ -97,6 +110,23 @@
|
||||
return appendAccountRunRecord(status, state, reason);
|
||||
}
|
||||
|
||||
async function ensureManualStepPrerequisites(step) {
|
||||
if (step !== 4) {
|
||||
return;
|
||||
}
|
||||
|
||||
const signupTabId = typeof getTabId === 'function'
|
||||
? await getTabId('signup-page')
|
||||
: null;
|
||||
const signupTabAlive = signupTabId && typeof isTabAlive === 'function'
|
||||
? await isTabAlive('signup-page')
|
||||
: Boolean(signupTabId);
|
||||
|
||||
if (!signupTabId || !signupTabAlive) {
|
||||
throw new Error('手动执行步骤 4 前,请先执行步骤 1 或步骤 2,确保认证页仍然打开并停留在验证码页。');
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStepData(step, payload) {
|
||||
switch (step) {
|
||||
case 1: {
|
||||
@@ -110,6 +140,8 @@
|
||||
if (payload.sub2apiGroupId !== undefined) updates.sub2apiGroupId = payload.sub2apiGroupId || null;
|
||||
if (payload.sub2apiDraftName !== undefined) updates.sub2apiDraftName = payload.sub2apiDraftName || null;
|
||||
if (payload.sub2apiProxyId !== undefined) updates.sub2apiProxyId = payload.sub2apiProxyId || null;
|
||||
if (payload.codex2apiSessionId !== undefined) updates.codex2apiSessionId = payload.codex2apiSessionId || null;
|
||||
if (payload.codex2apiOAuthState !== undefined) updates.codex2apiOAuthState = payload.codex2apiOAuthState || null;
|
||||
if (Object.keys(updates).length) {
|
||||
await setState(updates);
|
||||
}
|
||||
@@ -175,6 +207,13 @@
|
||||
});
|
||||
await addLog('当前 Hotmail 账号已自动标记为已用。', 'ok');
|
||||
}
|
||||
if (String(latestState.mailProvider || '').trim().toLowerCase() === '2925' && latestState.currentMail2925AccountId) {
|
||||
await patchMail2925Account(latestState.currentMail2925AccountId, {
|
||||
lastUsedAt: Date.now(),
|
||||
lastError: '',
|
||||
});
|
||||
await addLog('当前 2925 账号已记录最近使用时间。', 'ok');
|
||||
}
|
||||
if (isLuckmailProvider(latestState)) {
|
||||
const currentPurchase = getCurrentLuckmailPurchase(latestState);
|
||||
if (currentPurchase?.id) {
|
||||
@@ -289,6 +328,70 @@
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'SET_CONTRIBUTION_MODE': {
|
||||
const enabled = Boolean(message.payload?.enabled);
|
||||
const state = await ensureManualInteractionAllowed(enabled ? '进入贡献模式' : '退出贡献模式');
|
||||
if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) {
|
||||
throw new Error(enabled ? '当前有步骤正在执行,无法进入贡献模式。' : '当前有步骤正在执行,无法退出贡献模式。');
|
||||
}
|
||||
if (typeof setContributionMode !== 'function') {
|
||||
throw new Error('贡献模式切换能力未接入。');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: await setContributionMode(enabled),
|
||||
};
|
||||
}
|
||||
|
||||
case 'START_CONTRIBUTION_FLOW': {
|
||||
const state = await ensureManualInteractionAllowed('开始贡献');
|
||||
if (Object.values(state.stepStatuses || {}).some((status) => status === 'running')) {
|
||||
throw new Error('当前有步骤正在执行,无法开始贡献流程。');
|
||||
}
|
||||
if (typeof startContributionFlow !== 'function') {
|
||||
throw new Error('贡献 OAuth 流程尚未接入。');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: await startContributionFlow({
|
||||
nickname: message.payload?.nickname,
|
||||
qq: message.payload?.qq,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
case 'SET_CONTRIBUTION_PROFILE': {
|
||||
const state = await getState();
|
||||
if (!state?.contributionMode) {
|
||||
throw new Error('请先进入贡献模式。');
|
||||
}
|
||||
const nickname = String(message.payload?.nickname || '').trim();
|
||||
const qq = String(message.payload?.qq || '').trim();
|
||||
if (qq && !/^\d{1,20}$/.test(qq)) {
|
||||
throw new Error('QQ 只能填写数字,且长度不能超过 20 位。');
|
||||
}
|
||||
await setState({
|
||||
contributionNickname: nickname,
|
||||
contributionQq: qq,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
state: await getState(),
|
||||
};
|
||||
}
|
||||
|
||||
case 'POLL_CONTRIBUTION_STATUS': {
|
||||
if (typeof pollContributionStatus !== 'function') {
|
||||
throw new Error('贡献状态轮询能力尚未接入。');
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
state: await pollContributionStatus({
|
||||
reason: message.payload?.reason || 'sidepanel_poll',
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
case 'CLEAR_ACCOUNT_RUN_HISTORY': {
|
||||
const state = await getState();
|
||||
if (isAutoRunLockedState(state)) {
|
||||
@@ -320,6 +423,9 @@
|
||||
await ensureManualInteractionAllowed('手动执行步骤');
|
||||
}
|
||||
const step = message.payload.step;
|
||||
if (message.source === 'sidepanel') {
|
||||
await ensureManualStepPrerequisites(step);
|
||||
}
|
||||
if (message.source === 'sidepanel') {
|
||||
await invalidateDownstreamAfterStepRestart(step, { logLabel: `步骤 ${step} 重新执行` });
|
||||
}
|
||||
@@ -340,6 +446,17 @@
|
||||
|
||||
case 'AUTO_RUN': {
|
||||
clearStopRequest();
|
||||
if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') {
|
||||
await setContributionMode(true);
|
||||
if (typeof setState === 'function') {
|
||||
const contributionNickname = String(message.payload?.contributionNickname || '').trim();
|
||||
const contributionQq = String(message.payload?.contributionQq || '').trim();
|
||||
await setState({
|
||||
contributionNickname,
|
||||
contributionQq,
|
||||
});
|
||||
}
|
||||
}
|
||||
const state = await getState();
|
||||
if (getPendingAutoRunTimerPlan(state)) {
|
||||
throw new Error('已有自动运行倒计时计划,请先取消或立即开始。');
|
||||
@@ -354,6 +471,17 @@
|
||||
|
||||
case 'SCHEDULE_AUTO_RUN': {
|
||||
clearStopRequest();
|
||||
if (Boolean(message.payload?.contributionMode) && typeof setContributionMode === 'function') {
|
||||
await setContributionMode(true);
|
||||
if (typeof setState === 'function') {
|
||||
const contributionNickname = String(message.payload?.contributionNickname || '').trim();
|
||||
const contributionQq = String(message.payload?.contributionQq || '').trim();
|
||||
await setState({
|
||||
contributionNickname,
|
||||
contributionQq,
|
||||
});
|
||||
}
|
||||
}
|
||||
const totalRuns = normalizeRunCount(message.payload?.totalRuns || 1);
|
||||
return await scheduleAutoRun(totalRuns, {
|
||||
delayMinutes: message.payload?.delayMinutes,
|
||||
@@ -489,6 +617,52 @@
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
case 'UPSERT_MAIL2925_ACCOUNT': {
|
||||
const account = await upsertMail2925Account(message.payload || {});
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
case 'DELETE_MAIL2925_ACCOUNT': {
|
||||
await deleteMail2925Account(String(message.payload?.accountId || ''));
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
case 'DELETE_MAIL2925_ACCOUNTS': {
|
||||
const result = await deleteMail2925Accounts(String(message.payload?.mode || 'all'));
|
||||
return { ok: true, ...result };
|
||||
}
|
||||
|
||||
case 'SELECT_MAIL2925_ACCOUNT': {
|
||||
const account = await setCurrentMail2925Account(String(message.payload?.accountId || ''), {
|
||||
updateLastUsedAt: false,
|
||||
});
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
case 'PATCH_MAIL2925_ACCOUNT': {
|
||||
const account = await patchMail2925Account(
|
||||
String(message.payload?.accountId || ''),
|
||||
message.payload?.updates || {}
|
||||
);
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
case 'LOGIN_MAIL2925_ACCOUNT': {
|
||||
const accountId = String(message.payload?.accountId || '');
|
||||
const account = await setCurrentMail2925Account(accountId, {
|
||||
updateLastUsedAt: false,
|
||||
});
|
||||
if (typeof deps.ensureMail2925MailboxSession !== 'function') {
|
||||
throw new Error('2925 登录能力尚未接入。');
|
||||
}
|
||||
await deps.ensureMail2925MailboxSession({
|
||||
accountId: account.id,
|
||||
forceRelogin: Boolean(message.payload?.forceRelogin),
|
||||
actionLabel: '侧边栏手动登录 2925 账号',
|
||||
});
|
||||
return { ok: true, account };
|
||||
}
|
||||
|
||||
case 'LIST_LUCKMAIL_PURCHASES': {
|
||||
const purchases = await listLuckmailPurchasesForManagement();
|
||||
return { ok: true, purchases };
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundNavigationUtilsModule() {
|
||||
function createNavigationUtils(deps = {}) {
|
||||
const {
|
||||
DEFAULT_CODEX2API_URL,
|
||||
DEFAULT_SUB2API_URL,
|
||||
normalizeLocalCpaStep9Mode,
|
||||
} = deps;
|
||||
@@ -27,13 +28,36 @@
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function normalizeCodex2ApiUrl(rawUrl) {
|
||||
const input = (rawUrl || '').trim() || DEFAULT_CODEX2API_URL;
|
||||
const withProtocol = /^https?:\/\//i.test(input) ? input : `http://${input}`;
|
||||
const parsed = new URL(withProtocol);
|
||||
if (!parsed.pathname || parsed.pathname === '/' || parsed.pathname === '/admin') {
|
||||
parsed.pathname = '/admin/accounts';
|
||||
}
|
||||
parsed.hash = '';
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
function getPanelMode(state = {}) {
|
||||
return state.panelMode === 'sub2api' ? 'sub2api' : 'cpa';
|
||||
if (state.panelMode === 'sub2api') {
|
||||
return 'sub2api';
|
||||
}
|
||||
if (state.panelMode === 'codex2api') {
|
||||
return 'codex2api';
|
||||
}
|
||||
return 'cpa';
|
||||
}
|
||||
|
||||
function getPanelModeLabel(modeOrState) {
|
||||
const mode = typeof modeOrState === 'string' ? modeOrState : getPanelMode(modeOrState);
|
||||
return mode === 'sub2api' ? 'SUB2API' : 'CPA';
|
||||
if (mode === 'sub2api') {
|
||||
return 'SUB2API';
|
||||
}
|
||||
if (mode === 'codex2api') {
|
||||
return 'Codex2API';
|
||||
}
|
||||
return 'CPA';
|
||||
}
|
||||
|
||||
function isSignupPageHost(hostname = '') {
|
||||
@@ -126,6 +150,14 @@
|
||||
|| 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;
|
||||
}
|
||||
@@ -162,6 +194,7 @@
|
||||
isSignupPageHost,
|
||||
isSignupPasswordPageUrl,
|
||||
matchesSourceUrlFamily,
|
||||
normalizeCodex2ApiUrl,
|
||||
normalizeSub2ApiUrl,
|
||||
parseUrlSafely,
|
||||
shouldBypassStep9ForLocalCpa,
|
||||
|
||||
+104
-2
@@ -8,6 +8,7 @@
|
||||
closeConflictingTabsForSource,
|
||||
ensureContentScriptReadyOnTab,
|
||||
getPanelMode,
|
||||
normalizeCodex2ApiUrl,
|
||||
normalizeSub2ApiUrl,
|
||||
rememberSourceLastUrl,
|
||||
sendToContentScript,
|
||||
@@ -17,7 +18,74 @@
|
||||
SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
|
||||
} = deps;
|
||||
|
||||
function normalizeAdminKey(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function extractStateFromAuthUrl(authUrl = '') {
|
||||
try {
|
||||
return new URL(authUrl).searchParams.get('state') || '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getCodex2ApiErrorMessage(payload, responseStatus = 500) {
|
||||
const candidates = [
|
||||
payload?.error,
|
||||
payload?.message,
|
||||
payload?.detail,
|
||||
payload?.reason,
|
||||
];
|
||||
const message = candidates
|
||||
.map((value) => String(value || '').trim())
|
||||
.find(Boolean);
|
||||
return message || `Codex2API 请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
async function fetchCodex2ApiJson(origin, path, options = {}) {
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
method: options.method || 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Key': normalizeAdminKey(options.adminKey),
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getCodex2ApiErrorMessage(payload, response.status));
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('Codex2API 请求超时,请稍后重试。');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestOAuthUrlFromPanel(state, options = {}) {
|
||||
if (getPanelMode(state) === 'codex2api') {
|
||||
return requestCodex2ApiOAuthUrl(state, options);
|
||||
}
|
||||
if (getPanelMode(state) === 'sub2api') {
|
||||
return requestSub2ApiOAuthUrl(state, options);
|
||||
}
|
||||
@@ -60,7 +128,7 @@
|
||||
source: 'background',
|
||||
payload: {
|
||||
vpsPassword: state.vpsPassword,
|
||||
logStep: 6,
|
||||
logStep: 7,
|
||||
},
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
@@ -74,6 +142,39 @@
|
||||
return result || {};
|
||||
}
|
||||
|
||||
async function requestCodex2ApiOAuthUrl(state, options = {}) {
|
||||
const { logLabel = 'OAuth 刷新' } = options;
|
||||
const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
|
||||
const adminKey = normalizeAdminKey(state.codex2apiAdminKey);
|
||||
|
||||
if (!adminKey) {
|
||||
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
|
||||
}
|
||||
|
||||
const origin = new URL(codex2apiUrl).origin;
|
||||
await addLog(`${logLabel}:正在通过 Codex2API 协议生成 OAuth 授权链接...`);
|
||||
|
||||
const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/generate-auth-url', {
|
||||
adminKey,
|
||||
method: 'POST',
|
||||
body: {},
|
||||
});
|
||||
|
||||
const oauthUrl = String(result?.auth_url || result?.authUrl || '').trim();
|
||||
const sessionId = String(result?.session_id || result?.sessionId || '').trim();
|
||||
const oauthState = extractStateFromAuthUrl(oauthUrl);
|
||||
|
||||
if (!oauthUrl || !sessionId) {
|
||||
throw new Error('Codex2API 未返回有效的 auth_url 或 session_id。');
|
||||
}
|
||||
|
||||
return {
|
||||
oauthUrl,
|
||||
codex2apiSessionId: sessionId,
|
||||
codex2apiOAuthState: oauthState || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function requestSub2ApiOAuthUrl(state, options = {}) {
|
||||
const { logLabel = 'OAuth 刷新' } = options;
|
||||
const sub2apiUrl = normalizeSub2ApiUrl(state.sub2apiUrl);
|
||||
@@ -121,7 +222,7 @@
|
||||
sub2apiPassword: state.sub2apiPassword,
|
||||
sub2apiGroupName: groupName,
|
||||
sub2apiDefaultProxyName: state.sub2apiDefaultProxyName,
|
||||
logStep: 6,
|
||||
logStep: 7,
|
||||
},
|
||||
}, {
|
||||
responseTimeoutMs: SUB2API_STEP1_RESPONSE_TIMEOUT_MS,
|
||||
@@ -135,6 +236,7 @@
|
||||
|
||||
return {
|
||||
requestOAuthUrlFromPanel,
|
||||
requestCodex2ApiOAuthUrl,
|
||||
requestCpaOAuthUrl,
|
||||
requestSub2ApiOAuthUrl,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
chrome,
|
||||
ensureContentScriptReadyOnTab,
|
||||
ensureHotmailAccountForFlow,
|
||||
ensureMail2925AccountForFlow,
|
||||
ensureLuckmailPurchaseForFlow,
|
||||
isGeneratedAliasProvider,
|
||||
isReusableGeneratedAliasEmail,
|
||||
@@ -198,6 +199,15 @@
|
||||
const purchase = await ensureLuckmailPurchaseForFlow({ allowReuse: true });
|
||||
resolvedEmail = purchase.email_address;
|
||||
} else if (isGeneratedAliasProvider(state)) {
|
||||
if (Boolean(state?.mail2925UseAccountPool)
|
||||
&& String(state?.mailProvider || '').trim().toLowerCase() === '2925'
|
||||
&& typeof ensureMail2925AccountForFlow === 'function') {
|
||||
await ensureMail2925AccountForFlow({
|
||||
allowAllocate: true,
|
||||
preferredAccountId: state.currentMail2925AccountId || null,
|
||||
markUsed: true,
|
||||
});
|
||||
}
|
||||
if (!isReusableGeneratedAliasEmail?.(state, resolvedEmail)) {
|
||||
resolvedEmail = buildGeneratedAliasEmail(state);
|
||||
}
|
||||
|
||||
@@ -200,12 +200,6 @@
|
||||
break;
|
||||
}
|
||||
|
||||
if (effect.restartCurrentStep) {
|
||||
await addLog(`步骤 9:${getStep8EffectLabel(effect)},准备重新定位“继续”按钮并重试...`, 'warn');
|
||||
await sleepWithStop(STEP8_CLICK_RETRY_DELAY_MS);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (round >= STEP8_MAX_ROUNDS) {
|
||||
throw new Error(`步骤 9:连续 ${STEP8_MAX_ROUNDS} 轮点击“继续”后页面仍无反应。`);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
(function attachBackgroundStep8(root, factory) {
|
||||
root.MultiPageBackgroundStep8 = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep8Module() {
|
||||
const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000;
|
||||
|
||||
function createStep8Executor(deps = {}) {
|
||||
const {
|
||||
addLog,
|
||||
chrome,
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
|
||||
confirmCustomVerificationStepBypass,
|
||||
ensureMail2925MailboxSession,
|
||||
ensureStep8VerificationPageReady,
|
||||
executeStep7,
|
||||
getOAuthFlowRemainingMs,
|
||||
getOAuthFlowStepTimeoutMs,
|
||||
getMailConfig,
|
||||
@@ -19,17 +21,16 @@
|
||||
isVerificationMailPollingError,
|
||||
LUCKMAIL_PROVIDER,
|
||||
resolveVerificationStep,
|
||||
rerunStep7ForStep8Recovery,
|
||||
reuseOrCreateTab,
|
||||
setState,
|
||||
setStepStatus,
|
||||
shouldUseCustomRegistrationEmail,
|
||||
sleepWithStop,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS,
|
||||
throwIfStopped,
|
||||
} = deps;
|
||||
|
||||
async function getStep8ReadyTimeoutMs(actionLabel) {
|
||||
async function getStep8ReadyTimeoutMs(actionLabel, expectedOauthUrl = '') {
|
||||
if (typeof getOAuthFlowStepTimeoutMs !== 'function') {
|
||||
return 15000;
|
||||
}
|
||||
@@ -37,10 +38,11 @@
|
||||
return getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: 8,
|
||||
actionLabel,
|
||||
oauthUrl: expectedOauthUrl,
|
||||
});
|
||||
}
|
||||
|
||||
function getStep8RemainingTimeResolver() {
|
||||
function getStep8RemainingTimeResolver(expectedOauthUrl = '') {
|
||||
if (typeof getOAuthFlowRemainingMs !== 'function') {
|
||||
return undefined;
|
||||
}
|
||||
@@ -48,6 +50,7 @@
|
||||
return async (details = {}) => getOAuthFlowRemainingMs({
|
||||
step: 8,
|
||||
actionLabel: details.actionLabel || '登录验证码流程',
|
||||
oauthUrl: expectedOauthUrl,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -55,11 +58,51 @@
|
||||
return String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getExpectedMail2925MailboxEmail(state = {}) {
|
||||
if (Boolean(state?.mail2925UseAccountPool)) {
|
||||
const currentAccountId = String(state?.currentMail2925AccountId || '').trim();
|
||||
const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : [];
|
||||
const currentAccount = accounts.find((account) => String(account?.id || '') === currentAccountId) || null;
|
||||
const accountEmail = String(currentAccount?.email || '').trim().toLowerCase();
|
||||
if (accountEmail) {
|
||||
return accountEmail;
|
||||
}
|
||||
}
|
||||
|
||||
return String(state?.mail2925BaseEmail || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function focusOrOpenMailTab(mail) {
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
}
|
||||
|
||||
async function runStep8Attempt(state) {
|
||||
const mail = getMailConfig(state);
|
||||
if (mail.error) throw new Error(mail.error);
|
||||
|
||||
const stepStartedAt = Date.now();
|
||||
const verificationFilterAfterTimestamp = mail.provider === '2925'
|
||||
? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS)
|
||||
: stepStartedAt;
|
||||
const verificationSessionKey = `8:${stepStartedAt}`;
|
||||
const authTabId = await getTabId('signup-page');
|
||||
|
||||
if (authTabId) {
|
||||
@@ -73,7 +116,7 @@
|
||||
|
||||
throwIfStopped();
|
||||
const pageState = await ensureStep8VerificationPageReady({
|
||||
timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪'),
|
||||
timeoutMs: await getStep8ReadyTimeoutMs('确认登录验证码页已就绪', state?.oauthUrl || ''),
|
||||
});
|
||||
const shouldCompareVerificationEmail = mail.provider !== '2925';
|
||||
const displayedVerificationEmail = shouldCompareVerificationEmail
|
||||
@@ -106,23 +149,19 @@
|
||||
await addLog(`步骤 8:正在通过 ${mail.label} 轮询验证码...`);
|
||||
} else {
|
||||
await addLog(`步骤 8:正在打开${mail.label}...`);
|
||||
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
if (mail.provider === '2925' && typeof ensureMail2925MailboxSession === 'function') {
|
||||
await ensureMail2925MailboxSession({
|
||||
accountId: state.currentMail2925AccountId || null,
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool),
|
||||
expectedMailboxEmail: getExpectedMail2925MailboxEmail(state),
|
||||
actionLabel: 'Step 8: ensure 2925 mailbox session',
|
||||
});
|
||||
} else {
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
await focusOrOpenMailTab(mail);
|
||||
}
|
||||
} else {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
if (mail.provider === '2925') {
|
||||
await addLog(`步骤 8:将直接使用当前已登录的 ${mail.label} 轮询验证码。`, 'info');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,8 +169,10 @@
|
||||
...state,
|
||||
step8VerificationTargetEmail: displayedVerificationEmail || '',
|
||||
}, mail, {
|
||||
filterAfterTimestamp: stepStartedAt,
|
||||
getRemainingTimeMs: getStep8RemainingTimeResolver(),
|
||||
filterAfterTimestamp: verificationFilterAfterTimestamp,
|
||||
sessionKey: verificationSessionKey,
|
||||
disableTimeBudgetCap: mail.provider === '2925',
|
||||
getRemainingTimeMs: getStep8RemainingTimeResolver(state?.oauthUrl || ''),
|
||||
requestFreshCodeFirst: false,
|
||||
targetEmail: fixedTargetEmail,
|
||||
resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
|
||||
@@ -140,19 +181,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
async function rerunStep7ForStep8Recovery(options = {}) {
|
||||
const {
|
||||
logMessage = '步骤 8:正在回到步骤 7,重新发起登录验证码流程...',
|
||||
postStepDelayMs = 3000,
|
||||
} = options;
|
||||
const currentState = await getState();
|
||||
await addLog(logMessage, 'warn');
|
||||
await executeStep7(currentState);
|
||||
if (postStepDelayMs > 0) {
|
||||
await sleepWithStop(postStepDelayMs);
|
||||
}
|
||||
}
|
||||
|
||||
function isStep8RestartStep7Error(error) {
|
||||
const message = String(error?.message || error || '');
|
||||
return /STEP8_RESTART_STEP7::/i.test(message);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
(function attachBackgroundStep4(root, factory) {
|
||||
root.MultiPageBackgroundStep4 = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createBackgroundStep4Module() {
|
||||
const MAIL_2925_FILTER_LOOKBACK_MS = 10 * 60 * 1000;
|
||||
|
||||
function createStep4Executor(deps = {}) {
|
||||
const {
|
||||
addLog,
|
||||
chrome,
|
||||
completeStepFromBackground,
|
||||
confirmCustomVerificationStepBypass,
|
||||
ensureMail2925MailboxSession,
|
||||
getMailConfig,
|
||||
getTabId,
|
||||
HOTMAIL_PROVIDER,
|
||||
@@ -21,18 +24,61 @@
|
||||
throwIfStopped,
|
||||
} = deps;
|
||||
|
||||
function getExpectedMail2925MailboxEmail(state = {}) {
|
||||
if (Boolean(state?.mail2925UseAccountPool)) {
|
||||
const currentAccountId = String(state?.currentMail2925AccountId || '').trim();
|
||||
const accounts = Array.isArray(state?.mail2925Accounts) ? state.mail2925Accounts : [];
|
||||
const currentAccount = accounts.find((account) => String(account?.id || '') === currentAccountId) || null;
|
||||
const accountEmail = String(currentAccount?.email || '').trim().toLowerCase();
|
||||
if (accountEmail) {
|
||||
return accountEmail;
|
||||
}
|
||||
}
|
||||
|
||||
return String(state?.mail2925BaseEmail || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
async function focusOrOpenMailTab(mail) {
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeStep4(state) {
|
||||
const mail = getMailConfig(state);
|
||||
if (mail.error) throw new Error(mail.error);
|
||||
|
||||
const stepStartedAt = Date.now();
|
||||
const verificationFilterAfterTimestamp = mail.provider === '2925'
|
||||
? Math.max(0, stepStartedAt - MAIL_2925_FILTER_LOOKBACK_MS)
|
||||
: stepStartedAt;
|
||||
const verificationSessionKey = `4:${stepStartedAt}`;
|
||||
const signupTabId = await getTabId('signup-page');
|
||||
|
||||
if (!signupTabId) {
|
||||
throw new Error('认证页面标签页已关闭,无法继续步骤 4。');
|
||||
throw new Error('认证页面标签页已关闭,无法继续步骤 4。请先执行步骤 1 或步骤 2,重新打开认证页后再试。');
|
||||
}
|
||||
|
||||
await chrome.tabs.update(signupTabId, { active: true });
|
||||
throwIfStopped();
|
||||
await addLog('步骤 4:正在确认注册验证码页面是否就绪,必要时自动恢复密码页超时报错...');
|
||||
|
||||
const prepareResult = await sendToContentScriptResilient(
|
||||
'signup-page',
|
||||
{
|
||||
@@ -66,33 +112,41 @@
|
||||
}
|
||||
|
||||
throwIfStopped();
|
||||
if (mail.provider === HOTMAIL_PROVIDER || mail.provider === LUCKMAIL_PROVIDER || mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER) {
|
||||
if (
|
||||
mail.provider === HOTMAIL_PROVIDER
|
||||
|| mail.provider === LUCKMAIL_PROVIDER
|
||||
|| mail.provider === CLOUDFLARE_TEMP_EMAIL_PROVIDER
|
||||
) {
|
||||
await addLog(`步骤 4:正在通过 ${mail.label} 轮询验证码...`);
|
||||
} else if (mail.provider === '2925') {
|
||||
await addLog(`步骤 4:正在打开${mail.label}...`);
|
||||
if (typeof ensureMail2925MailboxSession === 'function') {
|
||||
await ensureMail2925MailboxSession({
|
||||
accountId: state.currentMail2925AccountId || null,
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: Boolean(state?.mail2925UseAccountPool),
|
||||
expectedMailboxEmail: getExpectedMail2925MailboxEmail(state),
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
} else {
|
||||
await focusOrOpenMailTab(mail);
|
||||
}
|
||||
await addLog(`步骤 4:将直接使用当前已登录的 ${mail.label} 轮询验证码。`, 'info');
|
||||
} else {
|
||||
await addLog(`步骤 4:正在打开${mail.label}...`);
|
||||
await focusOrOpenMailTab(mail);
|
||||
}
|
||||
|
||||
const alive = await isTabAlive(mail.source);
|
||||
if (alive) {
|
||||
if (mail.navigateOnReuse) {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
} else {
|
||||
const tabId = await getTabId(mail.source);
|
||||
await chrome.tabs.update(tabId, { active: true });
|
||||
}
|
||||
} else {
|
||||
await reuseOrCreateTab(mail.source, mail.url, {
|
||||
inject: mail.inject,
|
||||
injectSource: mail.injectSource,
|
||||
});
|
||||
}
|
||||
}
|
||||
const shouldRequestFreshCodeFirst = ![
|
||||
HOTMAIL_PROVIDER,
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER,
|
||||
].includes(mail.provider);
|
||||
|
||||
await resolveVerificationStep(4, state, mail, {
|
||||
filterAfterTimestamp: stepStartedAt,
|
||||
requestFreshCodeFirst: mail.provider === HOTMAIL_PROVIDER ? false : true,
|
||||
filterAfterTimestamp: verificationFilterAfterTimestamp,
|
||||
sessionKey: verificationSessionKey,
|
||||
disableTimeBudgetCap: mail.provider === '2925',
|
||||
requestFreshCodeFirst: shouldRequestFreshCodeFirst,
|
||||
resendIntervalMs: (mail.provider === HOTMAIL_PROVIDER || mail.provider === '2925')
|
||||
? 0
|
||||
: STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS,
|
||||
|
||||
@@ -23,6 +23,20 @@
|
||||
throwIfStopped,
|
||||
} = deps;
|
||||
|
||||
function isManagementSecretConfigError(error) {
|
||||
const message = String(typeof error === 'string' ? error : error?.message || '').trim();
|
||||
if (!message) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const mentionsSecret = /管理密钥|Admin Secret|X-Admin-Key|CPA Key/i.test(message);
|
||||
if (!mentionsSecret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /缺少|未配置|请输入|无效|错误|失败|401|认证失败|未授权|unauthorized|invalid/i.test(message);
|
||||
}
|
||||
|
||||
async function executeStep7(state) {
|
||||
if (!state.email) {
|
||||
throw new Error('缺少邮箱地址,请先完成步骤 3。');
|
||||
@@ -45,6 +59,7 @@
|
||||
? await getOAuthFlowStepTimeoutMs(180000, {
|
||||
step: 7,
|
||||
actionLabel: 'OAuth 登录并进入验证码页',
|
||||
oauthUrl,
|
||||
})
|
||||
: 180000;
|
||||
|
||||
@@ -98,6 +113,13 @@
|
||||
if (isAddPhoneAuthFailure(err)) {
|
||||
throw err;
|
||||
}
|
||||
if (isManagementSecretConfigError(err)) {
|
||||
await addLog(
|
||||
`步骤 7:检测到来源后台管理密钥缺失或错误,不再重试,当前流程停止。原因:${getErrorMessage(err)}`,
|
||||
'error'
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
lastError = err;
|
||||
if (attempt >= STEP6_MAX_ATTEMPTS) {
|
||||
break;
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
getTabId,
|
||||
isLocalhostOAuthCallbackUrl,
|
||||
isTabAlive,
|
||||
normalizeCodex2ApiUrl,
|
||||
normalizeSub2ApiUrl,
|
||||
rememberSourceLastUrl,
|
||||
reuseOrCreateTab,
|
||||
@@ -21,7 +22,86 @@
|
||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS,
|
||||
} = deps;
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function parseLocalhostCallback(rawUrl) {
|
||||
let parsed;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址格式无效,请重新执行步骤 9。');
|
||||
}
|
||||
|
||||
const code = normalizeString(parsed.searchParams.get('code'));
|
||||
const state = normalizeString(parsed.searchParams.get('state'));
|
||||
if (!code || !state) {
|
||||
throw new Error('步骤 10 捕获到的 localhost OAuth 回调地址缺少 code 或 state,请重新执行步骤 9。');
|
||||
}
|
||||
|
||||
return {
|
||||
url: parsed.toString(),
|
||||
code,
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
function getCodex2ApiErrorMessage(payload, responseStatus = 500) {
|
||||
const details = [
|
||||
payload?.error,
|
||||
payload?.message,
|
||||
payload?.detail,
|
||||
payload?.reason,
|
||||
]
|
||||
.map((value) => normalizeString(value))
|
||||
.find(Boolean);
|
||||
return details || `Codex2API 请求失败(HTTP ${responseStatus})。`;
|
||||
}
|
||||
|
||||
async function fetchCodex2ApiJson(origin, path, options = {}) {
|
||||
const controller = new AbortController();
|
||||
const timeoutMs = Math.max(1000, Math.floor(Number(options.timeoutMs) || 30000));
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${origin}${path}`, {
|
||||
method: options.method || 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
'X-Admin-Key': normalizeString(options.adminKey),
|
||||
},
|
||||
body: options.body === undefined ? undefined : JSON.stringify(options.body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
let payload = {};
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getCodex2ApiErrorMessage(payload, response.status));
|
||||
}
|
||||
|
||||
return payload;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('Codex2API 请求超时,请稍后重试。');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeStep10(state) {
|
||||
if (getPanelMode(state) === 'codex2api') {
|
||||
return executeCodex2ApiStep10(state);
|
||||
}
|
||||
if (getPanelMode(state) === 'sub2api') {
|
||||
return executeSub2ApiStep10(state);
|
||||
}
|
||||
@@ -79,7 +159,8 @@
|
||||
source: 'background',
|
||||
payload: { localhostUrl: state.localhostUrl, vpsPassword: state.vpsPassword },
|
||||
}, {
|
||||
timeoutMs: 30000,
|
||||
timeoutMs: 125000,
|
||||
responseTimeoutMs: 125000,
|
||||
retryDelayMs: 700,
|
||||
logMessage: '步骤 10:CPA 面板通信未就绪,正在等待页面恢复...',
|
||||
});
|
||||
@@ -89,6 +170,48 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function executeCodex2ApiStep10(state) {
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
}
|
||||
if (!state.localhostUrl) {
|
||||
throw new Error('缺少 localhost 回调地址,请先完成步骤 9。');
|
||||
}
|
||||
if (!state.codex2apiSessionId) {
|
||||
throw new Error('缺少 Codex2API 会话信息,请重新执行步骤 7。');
|
||||
}
|
||||
if (!normalizeString(state.codex2apiAdminKey)) {
|
||||
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
|
||||
}
|
||||
|
||||
const callback = parseLocalhostCallback(state.localhostUrl);
|
||||
const expectedState = normalizeString(state.codex2apiOAuthState);
|
||||
if (expectedState && expectedState !== callback.state) {
|
||||
throw new Error('Codex2API 回调 state 与当前授权会话不匹配,请重新执行步骤 7。');
|
||||
}
|
||||
|
||||
const codex2apiUrl = normalizeCodex2ApiUrl(state.codex2apiUrl);
|
||||
const origin = new URL(codex2apiUrl).origin;
|
||||
|
||||
await addLog('步骤 10:正在向 Codex2API 提交回调并创建账号...');
|
||||
const result = await fetchCodex2ApiJson(origin, '/api/admin/oauth/exchange-code', {
|
||||
adminKey: state.codex2apiAdminKey,
|
||||
method: 'POST',
|
||||
body: {
|
||||
session_id: state.codex2apiSessionId,
|
||||
code: callback.code,
|
||||
state: callback.state,
|
||||
},
|
||||
});
|
||||
|
||||
const verifiedStatus = normalizeString(result?.message) || 'Codex2API OAuth 账号添加成功';
|
||||
await addLog(`步骤 10:${verifiedStatus}`, 'ok');
|
||||
await completeStepFromBackground(10, {
|
||||
localhostUrl: callback.url,
|
||||
verifiedStatus,
|
||||
});
|
||||
}
|
||||
|
||||
async function executeSub2ApiStep10(state) {
|
||||
if (state.localhostUrl && !isLocalhostOAuthCallbackUrl(state.localhostUrl)) {
|
||||
throw new Error('步骤 9 捕获到的 localhost OAuth 回调地址无效,请重新执行步骤 9。');
|
||||
@@ -160,6 +283,7 @@
|
||||
|
||||
return {
|
||||
executeCpaStep10,
|
||||
executeCodex2ApiStep10,
|
||||
executeStep10,
|
||||
executeSub2ApiStep10,
|
||||
};
|
||||
|
||||
@@ -10,9 +10,11 @@
|
||||
confirmCustomVerificationStepBypassRequest,
|
||||
getHotmailVerificationPollConfig,
|
||||
getHotmailVerificationRequestTimestamp,
|
||||
handleMail2925LimitReachedError,
|
||||
getState,
|
||||
getTabId,
|
||||
HOTMAIL_PROVIDER,
|
||||
isMail2925LimitReachedError,
|
||||
isStopError,
|
||||
LUCKMAIL_PROVIDER,
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS,
|
||||
@@ -111,12 +113,15 @@
|
||||
|
||||
function getVerificationPollPayload(step, state, overrides = {}) {
|
||||
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,
|
||||
@@ -128,6 +133,7 @@
|
||||
senderFilters: ['openai', 'noreply', 'verify', 'auth', 'chatgpt', 'duckduckgo', 'forward'],
|
||||
subjectFilters: ['verify', 'verification', 'code', '验证码', 'confirm', 'login'],
|
||||
targetEmail: String(state?.step8VerificationTargetEmail || '').trim() || state.email,
|
||||
mail2925MatchTargetEmail,
|
||||
maxAttempts: is2925Provider ? MAIL_2925_VERIFICATION_MAX_ATTEMPTS : 5,
|
||||
intervalMs: is2925Provider ? MAIL_2925_VERIFICATION_INTERVAL_MS : 3000,
|
||||
...overrides,
|
||||
@@ -164,9 +170,10 @@
|
||||
const nextPayload = { ...payload };
|
||||
const intervalMs = Math.max(1, Number(nextPayload.intervalMs) || 3000);
|
||||
const baseMaxAttempts = Math.max(1, Number(nextPayload.maxAttempts) || 1);
|
||||
const disableTimeBudgetCap = Boolean(options.disableTimeBudgetCap);
|
||||
const remainingMs = await getRemainingTimeBudgetMs(step, options, actionLabel);
|
||||
|
||||
if (remainingMs !== null) {
|
||||
if (!disableTimeBudgetCap && remainingMs !== null) {
|
||||
nextPayload.maxAttempts = Math.max(
|
||||
1,
|
||||
Math.min(baseMaxAttempts, Math.floor(Math.max(0, remainingMs - 1000) / intervalMs) + 1)
|
||||
@@ -174,7 +181,7 @@
|
||||
}
|
||||
|
||||
const defaultResponseTimeoutMs = Math.max(45000, nextPayload.maxAttempts * intervalMs + 25000);
|
||||
const responseTimeoutMs = remainingMs === null
|
||||
const responseTimeoutMs = disableTimeBudgetCap || remainingMs === null
|
||||
? defaultResponseTimeoutMs
|
||||
: Math.max(1000, Math.min(defaultResponseTimeoutMs, remainingMs));
|
||||
|
||||
@@ -236,6 +243,62 @@
|
||||
return requestedAt;
|
||||
}
|
||||
|
||||
function shouldPreclear2925Mailbox(step, mail, options = {}) {
|
||||
if (mail?.provider !== '2925' || (step !== 4 && step !== 8)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !(Number(options.filterAfterTimestamp) > 0);
|
||||
}
|
||||
|
||||
async function clear2925MailboxBeforePolling(step, mail, options = {}) {
|
||||
if (!shouldPreclear2925Mailbox(step, mail, options)) {
|
||||
return;
|
||||
}
|
||||
|
||||
throwIfStopped();
|
||||
await addLog(`步骤 ${step}:开始刷新 2925 邮箱前先清空全部邮件,避免读取旧验证码邮件。`, 'warn');
|
||||
|
||||
try {
|
||||
const responseTimeoutMs = await getResponseTimeoutMsForStep(
|
||||
step,
|
||||
options,
|
||||
15000,
|
||||
'清空 2925 邮箱历史邮件'
|
||||
);
|
||||
const result = await sendToMailContentScriptResilient(
|
||||
mail,
|
||||
{
|
||||
type: 'DELETE_ALL_EMAILS',
|
||||
step,
|
||||
source: 'background',
|
||||
payload: {},
|
||||
},
|
||||
{
|
||||
timeoutMs: responseTimeoutMs,
|
||||
responseTimeoutMs,
|
||||
maxRecoveryAttempts: 2,
|
||||
}
|
||||
);
|
||||
|
||||
if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
}
|
||||
|
||||
if (result?.deleted === false) {
|
||||
await addLog(`步骤 ${step}:未能确认 2925 邮箱已清空,将继续刷新等待新邮件。`, 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
await addLog(`步骤 ${step}:2925 邮箱已预先清空,开始刷新等待新邮件。`, 'info');
|
||||
} catch (err) {
|
||||
if (isStopError(err)) {
|
||||
throw err;
|
||||
}
|
||||
await addLog(`步骤 ${step}:预清空 2925 邮箱失败,将继续刷新等待新邮件:${err.message}`, 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerPostSuccessMailboxCleanup(step, mail) {
|
||||
if (mail?.provider !== '2925') {
|
||||
return;
|
||||
@@ -364,6 +427,12 @@
|
||||
if (isStopError(err)) {
|
||||
throw err;
|
||||
}
|
||||
if (mail?.provider === '2925' && typeof isMail2925LimitReachedError === 'function' && isMail2925LimitReachedError(err)) {
|
||||
if (typeof handleMail2925LimitReachedError === 'function') {
|
||||
throw await handleMail2925LimitReachedError(step, err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
lastError = err;
|
||||
await addLog(`步骤 ${step}:${err.message}`, 'warn');
|
||||
}
|
||||
@@ -501,6 +570,12 @@
|
||||
if (isStopError(err)) {
|
||||
throw err;
|
||||
}
|
||||
if (mail?.provider === '2925' && typeof isMail2925LimitReachedError === 'function' && isMail2925LimitReachedError(err)) {
|
||||
if (typeof handleMail2925LimitReachedError === 'function') {
|
||||
throw await handleMail2925LimitReachedError(step, err);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
lastError = err;
|
||||
await addLog(`步骤 ${step}:${err.message}`, 'warn');
|
||||
if (round < maxRounds) {
|
||||
@@ -564,7 +639,7 @@
|
||||
getLegacyVerificationResendCountDefault(step, { requestFreshCodeFirst })
|
||||
)
|
||||
: getConfiguredVerificationResendCount(step, state, { requestFreshCodeFirst });
|
||||
const maxSubmitAttempts = 7;
|
||||
const maxSubmitAttempts = 15;
|
||||
const resendIntervalMs = Math.max(0, Number(options.resendIntervalMs) || 0);
|
||||
let lastResendAt = Number(options.lastResendAt) || 0;
|
||||
|
||||
@@ -572,6 +647,8 @@
|
||||
return nextFilterAfterTimestamp;
|
||||
};
|
||||
|
||||
await clear2925MailboxBeforePolling(step, mail, options);
|
||||
|
||||
if (requestFreshCodeFirst) {
|
||||
if (remainingAutomaticResendCount <= 0) {
|
||||
await addLog(`步骤 ${step}:当前自动重新发送验证码次数为 0,将直接使用当前时间窗口轮询邮箱。`, 'info');
|
||||
@@ -609,6 +686,7 @@
|
||||
for (let attempt = 1; attempt <= maxSubmitAttempts; attempt++) {
|
||||
const pollOptions = {
|
||||
excludeCodes: [...rejectedCodes],
|
||||
disableTimeBudgetCap: Boolean(options.disableTimeBudgetCap),
|
||||
getRemainingTimeMs: options.getRemainingTimeMs,
|
||||
maxResendRequests: remainingAutomaticResendCount,
|
||||
resendIntervalMs,
|
||||
|
||||
@@ -47,7 +47,15 @@
|
||||
return true;
|
||||
}
|
||||
|
||||
return /(?:认证失败|回调 URL 提交失败):\s*/i.test(text);
|
||||
if (/请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (/bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out/i.test(text)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /(?:认证失败|回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[::]?\s*/i.test(text);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
isActionEnabled,
|
||||
isVisibleElement,
|
||||
log,
|
||||
routeErrorPattern = null,
|
||||
simulateClick,
|
||||
sleep,
|
||||
throwIfStopped,
|
||||
@@ -65,9 +66,13 @@
|
||||
const detailMatched = detailPattern instanceof RegExp
|
||||
? detailPattern.test(text)
|
||||
: false;
|
||||
const routeErrorMatched = routeErrorPattern instanceof RegExp
|
||||
? routeErrorPattern.test(text)
|
||||
: false;
|
||||
const maxCheckAttemptsBlocked = /max_check_attempts/i.test(text);
|
||||
const userAlreadyExistsBlocked = /user_already_exists/i.test(text);
|
||||
|
||||
if (!titleMatched && !detailMatched && !maxCheckAttemptsBlocked) {
|
||||
if (!titleMatched && !detailMatched && !routeErrorMatched && !maxCheckAttemptsBlocked && !userAlreadyExistsBlocked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -78,7 +83,9 @@
|
||||
retryEnabled: isActionEnabled(retryButton),
|
||||
titleMatched,
|
||||
detailMatched,
|
||||
routeErrorMatched,
|
||||
maxCheckAttemptsBlocked,
|
||||
userAlreadyExistsBlocked,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,6 +155,12 @@
|
||||
);
|
||||
}
|
||||
|
||||
if (retryState.userAlreadyExistsBlocked) {
|
||||
throw new Error(
|
||||
'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。'
|
||||
);
|
||||
}
|
||||
|
||||
if (retryState.retryButton && retryState.retryEnabled) {
|
||||
idlePollCount = 0;
|
||||
clickCount += 1;
|
||||
@@ -199,6 +212,12 @@
|
||||
);
|
||||
}
|
||||
|
||||
if (finalRetryState.userAlreadyExistsBlocked) {
|
||||
throw new Error(
|
||||
'SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。'
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`${logLabel || `步骤 ${step || '?'}:重试页恢复`}失败:已连续点击“重试” ${maxClickAttempts} 次,页面仍未恢复。URL: ${location.href}`
|
||||
);
|
||||
|
||||
+366
-31
@@ -69,15 +69,141 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
// Find mail items
|
||||
// ============================================================
|
||||
|
||||
function findMailItems() {
|
||||
return document.querySelectorAll('div[sign="letter"]');
|
||||
function normalizeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getCurrentMailIds() {
|
||||
function isVisibleNode(node) {
|
||||
if (!node) return false;
|
||||
if (node.hidden) return false;
|
||||
|
||||
const style = typeof window.getComputedStyle === 'function'
|
||||
? window.getComputedStyle(node)
|
||||
: null;
|
||||
if (style && (style.display === 'none' || style.visibility === 'hidden')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const rect = typeof node.getBoundingClientRect === 'function'
|
||||
? node.getBoundingClientRect()
|
||||
: null;
|
||||
if (rect && rect.width <= 0 && rect.height <= 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function isLikelyMailItemNode(node) {
|
||||
if (!isVisibleNode(node)) {
|
||||
return false;
|
||||
}
|
||||
if (node.matches?.('div[sign="letter"]')) {
|
||||
return true;
|
||||
}
|
||||
if (node.querySelector?.('.nui-user, span.da0, [sign="trash"], [title="删除邮件"], [class*="subject"], [class*="sender"]')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const summaryText = normalizeText(
|
||||
node.getAttribute?.('aria-label')
|
||||
|| node.getAttribute?.('title')
|
||||
|| node.textContent
|
||||
);
|
||||
if (!summaryText) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /发件人|验证码|verification|chatgpt|openai|code/i.test(summaryText);
|
||||
}
|
||||
|
||||
function findMailItems() {
|
||||
const selectorGroups = [
|
||||
'div[sign="letter"]',
|
||||
'[role="option"][aria-label]',
|
||||
'[role="listitem"][aria-label]',
|
||||
'tr[aria-label]',
|
||||
'li[aria-label]',
|
||||
'div[aria-label]',
|
||||
];
|
||||
|
||||
for (const selector of selectorGroups) {
|
||||
const matches = Array.from(document.querySelectorAll(selector)).filter(isLikelyMailItemNode);
|
||||
if (matches.length > 0) {
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getMailTextBySelectors(item, selectors = []) {
|
||||
for (const selector of selectors) {
|
||||
const candidates = item.querySelectorAll(selector);
|
||||
for (const candidate of candidates) {
|
||||
const texts = [
|
||||
candidate.getAttribute?.('title'),
|
||||
candidate.getAttribute?.('aria-label'),
|
||||
candidate.textContent,
|
||||
];
|
||||
for (const text of texts) {
|
||||
const normalized = normalizeText(text);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMailSenderText(item) {
|
||||
return getMailTextBySelectors(item, [
|
||||
'.nui-user',
|
||||
'[class*="sender"]',
|
||||
'[class*="from"]',
|
||||
'[data-sender]',
|
||||
]);
|
||||
}
|
||||
|
||||
function getMailSubjectText(item) {
|
||||
return getMailTextBySelectors(item, [
|
||||
'span.da0',
|
||||
'[class*="subject"]',
|
||||
'[data-subject]',
|
||||
'strong',
|
||||
]);
|
||||
}
|
||||
|
||||
function getMailRowText(item) {
|
||||
const ariaLabel = normalizeText(item.getAttribute('aria-label'));
|
||||
const sender = getMailSenderText(item);
|
||||
const subject = getMailSubjectText(item);
|
||||
const fullText = normalizeText(item.textContent || '');
|
||||
return normalizeText([ariaLabel, sender, subject, fullText].filter(Boolean).join(' '));
|
||||
}
|
||||
|
||||
function getMailItemId(item, index = 0) {
|
||||
const candidates = [
|
||||
item.getAttribute('id'),
|
||||
item.getAttribute('data-id'),
|
||||
item.dataset?.id,
|
||||
item.getAttribute('data-key'),
|
||||
item.getAttribute('key'),
|
||||
].filter(Boolean);
|
||||
|
||||
if (candidates.length > 0) {
|
||||
return String(candidates[0]);
|
||||
}
|
||||
|
||||
return `${index}|${getMailRowText(item).slice(0, 240)}`;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
const ids = new Set();
|
||||
findMailItems().forEach(item => {
|
||||
const id = item.getAttribute('id') || '';
|
||||
if (id) ids.add(id);
|
||||
const sourceItems = items.length > 0 ? items : findMailItems();
|
||||
sourceItems.forEach((item, index) => {
|
||||
ids.add(getMailItemId(item, index));
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
@@ -90,7 +216,7 @@ function normalizeMinuteTimestamp(timestamp) {
|
||||
}
|
||||
|
||||
function parseMail163Timestamp(rawText) {
|
||||
const text = (rawText || '').replace(/\s+/g, ' ').trim();
|
||||
const text = normalizeText(rawText);
|
||||
if (!text) return null;
|
||||
|
||||
let match = text.match(/(\d{4})年(\d{1,2})月(\d{1,2})日\s+(\d{1,2}):(\d{2})/);
|
||||
@@ -107,6 +233,37 @@ function parseMail163Timestamp(rawText) {
|
||||
).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/今天\s*(\d{1,2}):(\d{2})/);
|
||||
if (match) {
|
||||
const [, hour, minute] = match;
|
||||
const now = new Date();
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
0,
|
||||
0
|
||||
).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/昨天\s*(\d{1,2}):(\d{2})/);
|
||||
if (match) {
|
||||
const [, hour, minute] = match;
|
||||
const now = new Date();
|
||||
now.setDate(now.getDate() - 1);
|
||||
return new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
Number(hour),
|
||||
Number(minute),
|
||||
0,
|
||||
0
|
||||
).getTime();
|
||||
}
|
||||
|
||||
match = text.match(/\b(\d{1,2}):(\d{2})\b/);
|
||||
if (match) {
|
||||
const [, hour, minute] = match;
|
||||
@@ -125,18 +282,48 @@ function parseMail163Timestamp(rawText) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getMailTimestamp(item) {
|
||||
const candidates = [];
|
||||
const timeCell = item.querySelector('.e00[title], [title*="年"][title*=":"]');
|
||||
if (timeCell?.getAttribute('title')) candidates.push(timeCell.getAttribute('title'));
|
||||
if (timeCell?.textContent) candidates.push(timeCell.textContent);
|
||||
function isLikelyMailTimestampText(value) {
|
||||
const text = normalizeText(value);
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
return /(\d{4}年\d{1,2}月\d{1,2}日\s+\d{1,2}:\d{2})|今天\s*\d{1,2}:\d{2}|昨天\s*\d{1,2}:\d{2}|\b\d{1,2}:\d{2}\b/.test(text);
|
||||
}
|
||||
|
||||
const titledNodes = item.querySelectorAll('[title]');
|
||||
titledNodes.forEach((node) => {
|
||||
const title = node.getAttribute('title');
|
||||
if (title) candidates.push(title);
|
||||
function collectMailTimestampCandidates(item) {
|
||||
const candidates = [];
|
||||
const seen = new Set();
|
||||
const pushCandidate = (value) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized || !isLikelyMailTimestampText(normalized) || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
candidates.push(normalized);
|
||||
};
|
||||
|
||||
const priorityNodes = item.querySelectorAll('.e00, [title], [aria-label], time, [class*="time"], [class*="date"]');
|
||||
priorityNodes.forEach((node) => {
|
||||
pushCandidate(node.getAttribute?.('title'));
|
||||
pushCandidate(node.getAttribute?.('aria-label'));
|
||||
pushCandidate(node.textContent);
|
||||
});
|
||||
|
||||
const textNodes = item.querySelectorAll('span, div, td, strong, b');
|
||||
textNodes.forEach((node) => {
|
||||
const text = normalizeText(node.textContent);
|
||||
if (text && text.length <= 24) {
|
||||
pushCandidate(text);
|
||||
}
|
||||
});
|
||||
|
||||
pushCandidate(item.getAttribute('aria-label'));
|
||||
pushCandidate(item.getAttribute('title'));
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function getMailTimestamp(item) {
|
||||
const candidates = collectMailTimestampCandidates(item);
|
||||
for (const candidate of candidates) {
|
||||
const parsed = parseMail163Timestamp(candidate);
|
||||
if (parsed) return parsed;
|
||||
@@ -145,6 +332,134 @@ function getMailTimestamp(item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function collectOpenedMailTextCandidates() {
|
||||
const texts = [];
|
||||
const seen = new Set();
|
||||
const pushText = (value) => {
|
||||
const normalized = normalizeText(value);
|
||||
if (!normalized || seen.has(normalized)) {
|
||||
return;
|
||||
}
|
||||
seen.add(normalized);
|
||||
texts.push(normalized);
|
||||
};
|
||||
|
||||
const selectors = [
|
||||
'.readHtml',
|
||||
'[class*="readmail"]',
|
||||
'[class*="mailread"]',
|
||||
'[class*="mailBody"]',
|
||||
'[class*="mailbody"]',
|
||||
'[class*="mail-content"]',
|
||||
'[class*="mailContent"]',
|
||||
'[class*="mail-detail"]',
|
||||
'[class*="mailDetail"]',
|
||||
'[class*="detail"] [class*="content"]',
|
||||
'[class*="read"] [class*="content"]',
|
||||
'[role="main"]',
|
||||
];
|
||||
|
||||
selectors.forEach((selector) => {
|
||||
document.querySelectorAll(selector).forEach((node) => {
|
||||
pushText(node.innerText || node.textContent);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll('iframe').forEach((frame) => {
|
||||
try {
|
||||
pushText(frame.contentDocument?.body?.innerText || frame.contentDocument?.body?.textContent);
|
||||
} catch {
|
||||
// Ignore cross-frame access errors and keep trying other candidates.
|
||||
}
|
||||
});
|
||||
|
||||
pushText(document.body?.innerText || document.body?.textContent);
|
||||
return texts.sort((a, b) => b.length - a.length);
|
||||
}
|
||||
|
||||
function selectOpenedMailTextCandidate(item, candidates = [], options = {}) {
|
||||
const subject = normalizeText(getMailSubjectText(item)).toLowerCase();
|
||||
const sender = normalizeText(getMailSenderText(item)).toLowerCase();
|
||||
const excludedSet = new Set((options.excludedTexts || []).map((value) => normalizeText(value)));
|
||||
const allowExcludedFallback = options.allowExcludedFallback !== false;
|
||||
|
||||
const pickCandidate = (source) => source.find((candidate) => {
|
||||
const lower = candidate.toLowerCase();
|
||||
if (subject && lower.includes(subject)) {
|
||||
return true;
|
||||
}
|
||||
if (sender && lower.includes(sender)) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(extractVerificationCode(candidate) && /chatgpt|openai|verification|验证码|login code/i.test(lower));
|
||||
}) || source[0] || '';
|
||||
|
||||
const filteredCandidates = candidates.filter((candidate) => !excludedSet.has(normalizeText(candidate)));
|
||||
const preferred = pickCandidate(filteredCandidates);
|
||||
if (preferred || !allowExcludedFallback) {
|
||||
return preferred;
|
||||
}
|
||||
|
||||
return pickCandidate(candidates);
|
||||
}
|
||||
|
||||
function readOpenedMailText(item, options = {}) {
|
||||
const candidates = collectOpenedMailTextCandidates();
|
||||
return selectOpenedMailTextCandidate(item, candidates, options);
|
||||
}
|
||||
|
||||
async function returnToInbox() {
|
||||
const inboxLink = document.querySelector('.nui-tree-item-text[title="收件箱"], [title="收件箱"]');
|
||||
if (inboxLink) {
|
||||
if (typeof simulateClick === 'function') {
|
||||
simulateClick(inboxLink);
|
||||
} else {
|
||||
inboxLink.click();
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < 20; i += 1) {
|
||||
if (findMailItems().length > 0) {
|
||||
return true;
|
||||
}
|
||||
await sleep(250);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function openMailAndGetMessageText(item) {
|
||||
const beforeCandidates = collectOpenedMailTextCandidates();
|
||||
const beforeText = selectOpenedMailTextCandidate(item, beforeCandidates);
|
||||
if (typeof simulateClick === 'function') {
|
||||
simulateClick(item);
|
||||
} else {
|
||||
item.click();
|
||||
}
|
||||
|
||||
let openedText = '';
|
||||
for (let i = 0; i < 24; i += 1) {
|
||||
await sleep(250);
|
||||
const candidate = readOpenedMailText(item, {
|
||||
excludedTexts: beforeCandidates,
|
||||
allowExcludedFallback: false,
|
||||
});
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
openedText = candidate;
|
||||
if (extractVerificationCode(candidate)) {
|
||||
break;
|
||||
}
|
||||
if (candidate !== beforeText && candidate.length > beforeText.length + 24) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await returnToInbox();
|
||||
return openedText;
|
||||
}
|
||||
|
||||
function scheduleEmailCleanup(item, step) {
|
||||
setTimeout(() => {
|
||||
Promise.resolve(deleteEmail(item, step)).catch(() => {
|
||||
@@ -199,7 +514,7 @@ async function handlePollEmail(step, payload) {
|
||||
log(`步骤 ${step}:邮件列表已加载,共 ${items.length} 封邮件`);
|
||||
|
||||
// Snapshot existing mail IDs
|
||||
const existingMailIds = getCurrentMailIds();
|
||||
const existingMailIds = getCurrentMailIds(items);
|
||||
log(`步骤 ${step}:已记录当前 ${existingMailIds.size} 封旧邮件快照`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
@@ -215,38 +530,58 @@ async function handlePollEmail(step, payload) {
|
||||
const allItems = findMailItems();
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
|
||||
for (const item of allItems) {
|
||||
const id = item.getAttribute('id') || '';
|
||||
for (let index = 0; index < allItems.length; index++) {
|
||||
const item = allItems[index];
|
||||
const id = getMailItemId(item, index);
|
||||
const mailTimestamp = getMailTimestamp(item);
|
||||
const mailMinute = normalizeMinuteTimestamp(mailTimestamp || 0);
|
||||
const passesTimeFilter = !filterAfterMinute || (mailMinute && mailMinute >= filterAfterMinute);
|
||||
const shouldBypassOldSnapshot = Boolean(filterAfterMinute && passesTimeFilter && mailMinute > 0);
|
||||
|
||||
if (!passesTimeFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!useFallback && !shouldBypassOldSnapshot && existingMailIds.has(id)) continue;
|
||||
if (!useFallback && existingMailIds.has(id)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const senderEl = item.querySelector('.nui-user');
|
||||
const sender = senderEl ? senderEl.textContent.toLowerCase() : '';
|
||||
const sender = getMailSenderText(item).toLowerCase();
|
||||
const subject = getMailSubjectText(item);
|
||||
const rowText = getMailRowText(item);
|
||||
const ariaLabel = normalizeText(item.getAttribute('aria-label')).toLowerCase();
|
||||
const combinedText = normalizeText([subject, ariaLabel, rowText].filter(Boolean).join(' '));
|
||||
|
||||
const subjectEl = item.querySelector('span.da0');
|
||||
const subject = subjectEl ? subjectEl.textContent : '';
|
||||
if (!mailTimestamp) {
|
||||
log(`步骤 ${step}:邮件 ${id.slice(0, 60)} 未读取到时间,已跳过时间窗口校验后的文本匹配阶段。`, 'info');
|
||||
}
|
||||
|
||||
const ariaLabel = (item.getAttribute('aria-label') || '').toLowerCase();
|
||||
|
||||
const senderMatch = senderFilters.some(f => sender.includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
|
||||
const subjectMatch = subjectFilters.some(f => subject.toLowerCase().includes(f.toLowerCase()) || ariaLabel.includes(f.toLowerCase()));
|
||||
const senderMatch = senderFilters.some((filter) => {
|
||||
const normalizedFilter = String(filter || '').toLowerCase();
|
||||
return normalizedFilter && (sender.includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
|
||||
});
|
||||
const subjectMatch = subjectFilters.some((filter) => {
|
||||
const normalizedFilter = String(filter || '').toLowerCase();
|
||||
return normalizedFilter && (subject.toLowerCase().includes(normalizedFilter) || ariaLabel.includes(normalizedFilter) || rowText.toLowerCase().includes(normalizedFilter));
|
||||
});
|
||||
|
||||
if (senderMatch || subjectMatch) {
|
||||
const code = extractVerificationCode(subject + ' ' + ariaLabel);
|
||||
let code = extractVerificationCode(combinedText);
|
||||
let codeSource = '邮件列表';
|
||||
|
||||
if (!code) {
|
||||
const openedText = await openMailAndGetMessageText(item);
|
||||
code = extractVerificationCode(openedText);
|
||||
if (code) {
|
||||
codeSource = '邮件正文';
|
||||
}
|
||||
}
|
||||
|
||||
if (code && excludedCodeSet.has(code)) {
|
||||
log(`步骤 ${step}:跳过排除的验证码:${code}`, 'info');
|
||||
} else if (code && !seenCodes.has(code)) {
|
||||
seenCodes.add(code);
|
||||
persistSeenCodes();
|
||||
const source = useFallback && existingMailIds.has(id) ? '回退匹配邮件' : '新邮件';
|
||||
const source = useFallback && existingMailIds.has(id) ? `回退匹配${codeSource}` : `新邮件${codeSource}`;
|
||||
const timeLabel = mailTimestamp ? `,时间:${new Date(mailTimestamp).toLocaleString('zh-CN', { hour12: false })}` : '';
|
||||
log(`步骤 ${step}:已找到验证码:${code}(来源:${source}${timeLabel},主题:${subject.slice(0, 40)})`, 'ok');
|
||||
|
||||
|
||||
+555
-31
@@ -53,6 +53,10 @@ async function persistSeenCodes() {
|
||||
}
|
||||
|
||||
function buildSeenCodeSessionKey(step, payload = {}) {
|
||||
const explicitSessionKey = String(payload?.sessionKey || '').trim();
|
||||
if (explicitSessionKey) {
|
||||
return explicitSessionKey;
|
||||
}
|
||||
const timestamp = Number(payload?.filterAfterTimestamp);
|
||||
if (Number.isFinite(timestamp) && timestamp > 0) {
|
||||
return `${step}:${timestamp}`;
|
||||
@@ -77,6 +81,16 @@ async function ensureSeenCodesSession(step, payload = {}) {
|
||||
}
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
if (message.type === 'ENSURE_MAIL2925_SESSION') {
|
||||
resetStopState();
|
||||
ensureMail2925Session(message.payload).then((result) => {
|
||||
sendResponse(result);
|
||||
}).catch((err) => {
|
||||
sendResponse({ error: err?.message || String(err || '2925 登录失败') });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
resetStopState();
|
||||
handlePollEmail(message.step, message.payload).then((result) => {
|
||||
@@ -95,8 +109,11 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
|
||||
}
|
||||
|
||||
if (message.type === 'DELETE_ALL_EMAILS') {
|
||||
Promise.resolve(deleteAllMailboxEmails(message.step)).catch(() => {});
|
||||
sendResponse({ ok: true });
|
||||
Promise.resolve(deleteAllMailboxEmails(message.step)).then((deleted) => {
|
||||
sendResponse({ ok: true, deleted });
|
||||
}).catch((err) => {
|
||||
sendResponse({ ok: false, error: err?.message || String(err || '删除邮件失败') });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -143,11 +160,76 @@ const MAIL_SELECT_ALL_SELECTORS = [
|
||||
'[class*="checkbox"]',
|
||||
];
|
||||
const MAIL_ACTION_CANDIDATE_SELECTORS = 'button, [role="button"], a, label, span, div';
|
||||
const MAIL2925_LIMIT_ERROR_PREFIX = 'MAIL2925_LIMIT_REACHED::';
|
||||
const MAIL2925_LOGIN_INPUT_SELECTORS = [
|
||||
'input[type="email"]',
|
||||
'input[name*="mail"]',
|
||||
'input[id*="mail"]',
|
||||
'input[name*="user"]',
|
||||
'input[id*="user"]',
|
||||
'input[placeholder*="邮箱"]',
|
||||
'input[placeholder*="账号"]',
|
||||
'input[placeholder*="用户名"]',
|
||||
'input[type="text"]',
|
||||
];
|
||||
const MAIL2925_PASSWORD_INPUT_SELECTORS = [
|
||||
'input[type="password"]',
|
||||
];
|
||||
const MAIL2925_LOGIN_BUTTON_SELECTORS = [
|
||||
'button[type="submit"]',
|
||||
'.ivu-btn-primary',
|
||||
'.el-button--primary',
|
||||
'[class*="login"]',
|
||||
'[class*="Login"]',
|
||||
];
|
||||
const MAIL2925_LOGIN_BUTTON_PATTERNS = [
|
||||
/登录/i,
|
||||
/login/i,
|
||||
/立即登录/i,
|
||||
/submit/i,
|
||||
];
|
||||
const MAIL2925_AGREEMENT_PATTERNS = [
|
||||
/我已阅读并同意/,
|
||||
/服务协议/,
|
||||
/隐私政策/,
|
||||
];
|
||||
const MAIL2925_REMEMBER_LOGIN_PATTERNS = [
|
||||
/30天内免登录/,
|
||||
/免登录/,
|
||||
/记住登录/,
|
||||
/保持登录/,
|
||||
];
|
||||
|
||||
function normalizeNodeText(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function buildMail2925LimitError(message = '') {
|
||||
const normalized = normalizeNodeText(message || '子邮箱已达上限邮箱') || '子邮箱已达上限邮箱';
|
||||
return new Error(`${MAIL2925_LIMIT_ERROR_PREFIX}${normalized}`);
|
||||
}
|
||||
|
||||
function getPageTextSample(limit = 4000) {
|
||||
return normalizeNodeText(document.body?.innerText || document.body?.textContent || '').slice(0, limit);
|
||||
}
|
||||
|
||||
function detectMail2925LimitMessage() {
|
||||
const text = getPageTextSample();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const match = text.match(/子邮箱.{0,12}已达上限|已达上限邮箱|子邮箱上限|邮箱已达上限/);
|
||||
return match ? match[0] : '';
|
||||
}
|
||||
|
||||
function throwIfMail2925LimitReached() {
|
||||
const limitMessage = detectMail2925LimitMessage();
|
||||
if (limitMessage) {
|
||||
throw buildMail2925LimitError(limitMessage);
|
||||
}
|
||||
}
|
||||
|
||||
function isVisibleNode(node) {
|
||||
if (!node) return false;
|
||||
if (node.hidden) return false;
|
||||
@@ -201,6 +283,19 @@ function findActionBySelectors(selectors = []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findVisibleInputBySelectors(selectors = []) {
|
||||
for (const selector of selectors) {
|
||||
const candidates = document.querySelectorAll(selector);
|
||||
for (const candidate of candidates) {
|
||||
if (!isVisibleNode(candidate) || candidate.disabled || candidate.readOnly) {
|
||||
continue;
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findToolbarActionButton(patterns = [], selectors = []) {
|
||||
const directMatch = findActionBySelectors(selectors);
|
||||
if (directMatch) {
|
||||
@@ -224,6 +319,29 @@ function findToolbarActionButton(patterns = [], selectors = []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLoginButton() {
|
||||
const directMatch = findActionBySelectors(MAIL2925_LOGIN_BUTTON_SELECTORS);
|
||||
if (directMatch) {
|
||||
return directMatch;
|
||||
}
|
||||
|
||||
const candidates = document.querySelectorAll(MAIL_ACTION_CANDIDATE_SELECTORS);
|
||||
for (const candidate of candidates) {
|
||||
const target = resolveActionTarget(candidate);
|
||||
if (!isVisibleNode(target) || isMailItemNode(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const text = normalizeNodeText(target.innerText || target.textContent || '');
|
||||
const label = normalizeNodeText(target.getAttribute?.('aria-label') || target.getAttribute?.('title') || '');
|
||||
if (MAIL2925_LOGIN_BUTTON_PATTERNS.some((pattern) => pattern.test(text) || pattern.test(label))) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function findRefreshButton() {
|
||||
return findToolbarActionButton([
|
||||
/刷新/i,
|
||||
@@ -246,6 +364,235 @@ function findSelectAllControl() {
|
||||
return findActionBySelectors(MAIL_SELECT_ALL_SELECTORS);
|
||||
}
|
||||
|
||||
function findMail2925LoginEmailInput() {
|
||||
const candidates = Array.from(document.querySelectorAll(MAIL2925_LOGIN_INPUT_SELECTORS.join(', ')));
|
||||
for (const candidate of candidates) {
|
||||
if (!isVisibleNode(candidate) || candidate.disabled || candidate.readOnly) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.type === 'password') {
|
||||
continue;
|
||||
}
|
||||
const identifier = normalizeNodeText([
|
||||
candidate.name,
|
||||
candidate.id,
|
||||
candidate.placeholder,
|
||||
candidate.getAttribute?.('aria-label'),
|
||||
candidate.getAttribute?.('autocomplete'),
|
||||
].join(' ')).toLowerCase();
|
||||
if (/邮箱|账号|用户|mail|user|login/.test(identifier) || candidate.type === 'email') {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findMail2925LoginPasswordInput() {
|
||||
return findVisibleInputBySelectors(MAIL2925_PASSWORD_INPUT_SELECTORS);
|
||||
}
|
||||
|
||||
function findAgreementContainer() {
|
||||
const candidates = document.querySelectorAll('label, div, span, p, form');
|
||||
for (const candidate of candidates) {
|
||||
if (!isVisibleNode(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const text = normalizeNodeText(candidate.innerText || candidate.textContent || '');
|
||||
if (!text) {
|
||||
continue;
|
||||
}
|
||||
if (MAIL2925_AGREEMENT_PATTERNS.every((pattern) => pattern.test(text) || /我已阅读并同意/.test(text))) {
|
||||
return candidate;
|
||||
}
|
||||
if (/我已阅读并同意/.test(text) && (/服务协议/.test(text) || /隐私政策/.test(text))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isAgreementText(text = '') {
|
||||
const normalizedText = normalizeNodeText(text);
|
||||
if (!normalizedText || MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(normalizedText))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return /我已阅读并同意/.test(normalizedText)
|
||||
|| (/服务协议/.test(normalizedText) && /隐私政策/.test(normalizedText));
|
||||
}
|
||||
|
||||
function getCheckboxContextText(target) {
|
||||
if (!target) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const textParts = [];
|
||||
const candidates = [
|
||||
target,
|
||||
target.closest?.('label'),
|
||||
target.parentElement,
|
||||
target.parentElement?.parentElement,
|
||||
target.nextElementSibling,
|
||||
target.previousElementSibling,
|
||||
target.closest?.('label, div, span, p, li, form'),
|
||||
].filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const text = normalizeNodeText(candidate.innerText || candidate.textContent || '');
|
||||
if (text) {
|
||||
textParts.push(text);
|
||||
}
|
||||
}
|
||||
|
||||
return normalizeNodeText(textParts.join(' '));
|
||||
}
|
||||
|
||||
function findAgreementCheckbox() {
|
||||
const genericCheckboxes = document.querySelectorAll('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
|
||||
for (const checkbox of genericCheckboxes) {
|
||||
const target = resolveActionTarget(checkbox);
|
||||
if (!isVisibleNode(target)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const contextText = getCheckboxContextText(target);
|
||||
if (MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) {
|
||||
continue;
|
||||
}
|
||||
if (isAgreementText(contextText)) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
|
||||
const agreementContainer = findAgreementContainer();
|
||||
if (agreementContainer) {
|
||||
const checkbox = agreementContainer.querySelector('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
|
||||
if (checkbox) {
|
||||
const target = resolveActionTarget(checkbox);
|
||||
const contextText = getCheckboxContextText(target);
|
||||
if (!MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
const nearbyCheckbox = agreementContainer.parentElement?.querySelector?.('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox');
|
||||
if (nearbyCheckbox) {
|
||||
const target = resolveActionTarget(nearbyCheckbox);
|
||||
const contextText = getCheckboxContextText(target);
|
||||
if (!MAIL2925_REMEMBER_LOGIN_PATTERNS.some((pattern) => pattern.test(contextText))) {
|
||||
return target;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function ensureAgreementChecked() {
|
||||
const checkboxes = Array.from(document.querySelectorAll('input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox'))
|
||||
.map((checkbox) => resolveActionTarget(checkbox))
|
||||
.filter((target, index, list) => target && isVisibleNode(target) && list.indexOf(target) === index);
|
||||
|
||||
if (!checkboxes.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let changed = false;
|
||||
for (const checkbox of checkboxes) {
|
||||
if (isCheckboxChecked(checkbox)) {
|
||||
continue;
|
||||
}
|
||||
simulateClick(checkbox);
|
||||
changed = true;
|
||||
await sleep(120);
|
||||
}
|
||||
|
||||
return changed || checkboxes.every((checkbox) => isCheckboxChecked(checkbox));
|
||||
}
|
||||
|
||||
function detectMail2925ViewState() {
|
||||
const limitMessage = detectMail2925LimitMessage();
|
||||
if (limitMessage) {
|
||||
return { view: 'limit', limitMessage };
|
||||
}
|
||||
|
||||
const mailboxEmail = getMail2925DisplayedMailboxEmail();
|
||||
if (findMailItems().length > 0 || mailboxEmail) {
|
||||
return { view: 'mailbox', limitMessage: '', mailboxEmail };
|
||||
}
|
||||
|
||||
if (findMail2925LoginPasswordInput() && findMail2925LoginEmailInput()) {
|
||||
return { view: 'login', limitMessage: '' };
|
||||
}
|
||||
|
||||
const pageText = getPageTextSample();
|
||||
if (/欢迎使用邮箱|登录|login/i.test(pageText) && /密码|password/i.test(pageText)) {
|
||||
return { view: 'login', limitMessage: '' };
|
||||
}
|
||||
|
||||
return { view: 'unknown', limitMessage: '' };
|
||||
}
|
||||
|
||||
function getMail2925DisplayedMailboxEmail() {
|
||||
const directSelectors = [
|
||||
'.right-header',
|
||||
'[class~="right-header"]',
|
||||
'[class*="right-header"]',
|
||||
'[class*="user"] [class*="mail"]',
|
||||
'[class*="user"] [class*="email"]',
|
||||
'[class*="account"] [class*="mail"]',
|
||||
'[class*="account"] [class*="email"]',
|
||||
'[class*="header"] [class*="mail"]',
|
||||
'[class*="header"] [class*="email"]',
|
||||
];
|
||||
|
||||
for (const selector of directSelectors) {
|
||||
const candidates = document.querySelectorAll(selector);
|
||||
for (const candidate of candidates) {
|
||||
if (!isVisibleNode(candidate) || isMailItemNode(candidate)) {
|
||||
continue;
|
||||
}
|
||||
const email = extractEmails(candidate.textContent || candidate.innerText || '')
|
||||
.find((value) => /@2925\.com$/i.test(String(value || '').trim())) || '';
|
||||
if (email) {
|
||||
return email;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const topCandidates = Array.from(document.querySelectorAll('body *'))
|
||||
.filter((node) => {
|
||||
if (!isVisibleNode(node) || isMailItemNode(node)) {
|
||||
return false;
|
||||
}
|
||||
const rect = typeof node.getBoundingClientRect === 'function'
|
||||
? node.getBoundingClientRect()
|
||||
: null;
|
||||
if (!rect) return false;
|
||||
return rect.top >= 0 && rect.top <= Math.max(window.innerHeight * 0.35, 280);
|
||||
})
|
||||
.map((node) => {
|
||||
const email = extractEmails(node.textContent || node.innerText || '')
|
||||
.find((value) => /@2925\.com$/i.test(String(value || '').trim())) || '';
|
||||
return { node, email };
|
||||
})
|
||||
.filter((entry) => entry.email);
|
||||
|
||||
if (!topCandidates.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
topCandidates.sort((left, right) => {
|
||||
const leftRect = left.node.getBoundingClientRect();
|
||||
const rightRect = right.node.getBoundingClientRect();
|
||||
if (leftRect.top !== rightRect.top) {
|
||||
return leftRect.top - rightRect.top;
|
||||
}
|
||||
return leftRect.left - rightRect.left;
|
||||
});
|
||||
|
||||
return topCandidates[0]?.email || '';
|
||||
}
|
||||
|
||||
function isCheckboxChecked(node) {
|
||||
const checkbox = node?.matches?.('input[type="checkbox"], [role="checkbox"]')
|
||||
? node
|
||||
@@ -344,6 +691,46 @@ function extractVerificationCode(text, strictChatGPTCodeOnly = false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractEmails(text = '') {
|
||||
const matches = String(text || '').match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi) || [];
|
||||
return [...new Set(matches.map((item) => item.toLowerCase()))];
|
||||
}
|
||||
|
||||
function emailMatchesTarget(candidate, targetEmail) {
|
||||
const normalizedCandidate = String(candidate || '').trim().toLowerCase();
|
||||
const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
|
||||
return Boolean(normalizedCandidate && normalizedTarget && normalizedCandidate === normalizedTarget);
|
||||
}
|
||||
|
||||
function getTargetEmailMatchState(text, targetEmail) {
|
||||
const normalizedTarget = String(targetEmail || '').trim().toLowerCase();
|
||||
if (!normalizedTarget) {
|
||||
return { matches: true, hasExplicitEmail: false };
|
||||
}
|
||||
|
||||
const normalizedText = String(text || '').toLowerCase();
|
||||
if (normalizedText.includes(normalizedTarget)) {
|
||||
return { matches: true, hasExplicitEmail: true };
|
||||
}
|
||||
|
||||
const extractedEmails = extractEmails(normalizedText);
|
||||
if (!extractedEmails.length) {
|
||||
return { matches: true, hasExplicitEmail: false };
|
||||
}
|
||||
|
||||
return {
|
||||
matches: extractedEmails.some((candidate) => emailMatchesTarget(candidate, normalizedTarget)),
|
||||
hasExplicitEmail: true,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMinuteTimestamp(timestamp) {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return 0;
|
||||
const date = new Date(timestamp);
|
||||
date.setSeconds(0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function parseMailItemTimestamp(item) {
|
||||
const timeText = getMailItemTimeText(item);
|
||||
if (!timeText) return null;
|
||||
@@ -474,6 +861,10 @@ async function openMailAndDeleteAfterRead(item, step) {
|
||||
async function deleteAllMailboxEmails(step) {
|
||||
try {
|
||||
await returnToInbox();
|
||||
const initialItems = findMailItems();
|
||||
if (initialItems.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const selectAllControl = findSelectAllControl();
|
||||
if (!selectAllControl) {
|
||||
@@ -491,8 +882,15 @@ async function deleteAllMailboxEmails(step) {
|
||||
}
|
||||
|
||||
simulateClick(deleteButton);
|
||||
await sleepRandom(200, 500);
|
||||
for (let attempt = 0; attempt < 20; attempt += 1) {
|
||||
await sleep(250);
|
||||
if (findMailItems().length === 0) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
await sleepRandom(200, 500);
|
||||
return findMailItems().length === 0;
|
||||
} catch (err) {
|
||||
console.warn(MAIL2925_PREFIX, `Step ${step}: delete-all cleanup failed:`, err?.message || err);
|
||||
return false;
|
||||
@@ -500,6 +898,9 @@ async function deleteAllMailboxEmails(step) {
|
||||
}
|
||||
|
||||
async function refreshInbox() {
|
||||
if (typeof throwIfMail2925LimitReached === 'function') {
|
||||
throwIfMail2925LimitReached();
|
||||
}
|
||||
const refreshBtn = findRefreshButton();
|
||||
if (refreshBtn) {
|
||||
simulateClick(refreshBtn);
|
||||
@@ -514,6 +915,129 @@ async function refreshInbox() {
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForMail2925View(targetView, timeoutMs = 45000) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt <= timeoutMs) {
|
||||
throwIfStopped();
|
||||
const currentState = detectMail2925ViewState();
|
||||
if (currentState.view === 'limit') {
|
||||
throw buildMail2925LimitError(currentState.limitMessage);
|
||||
}
|
||||
if (currentState.view === targetView) {
|
||||
return currentState;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
return detectMail2925ViewState();
|
||||
}
|
||||
|
||||
async function ensureMail2925Session(payload = {}) {
|
||||
const email = String(payload?.email || '').trim();
|
||||
const password = String(payload?.password || '');
|
||||
const forceLogin = Boolean(payload?.forceLogin);
|
||||
const allowLoginWhenOnLoginPage = payload?.allowLoginWhenOnLoginPage !== false;
|
||||
log(`步骤 0:2925 登录态检查开始,当前地址 ${location.href},forceLogin=${forceLogin ? 'true' : 'false'}`, 'info');
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt += 1) {
|
||||
throwIfStopped();
|
||||
const currentState = detectMail2925ViewState();
|
||||
log(`步骤 0:2925 登录页状态探测,第 ${attempt + 1}/10 次,状态=${currentState.view},地址=${location.href}`, 'info');
|
||||
if (currentState.view === 'limit') {
|
||||
return {
|
||||
ok: false,
|
||||
loggedIn: false,
|
||||
currentView: 'limit',
|
||||
limitReached: true,
|
||||
limitMessage: currentState.limitMessage,
|
||||
};
|
||||
}
|
||||
if (currentState.view === 'mailbox' && !forceLogin) {
|
||||
return {
|
||||
ok: true,
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: currentState.mailboxEmail || '',
|
||||
};
|
||||
}
|
||||
if (currentState.view === 'login') {
|
||||
if (!forceLogin && !allowLoginWhenOnLoginPage) {
|
||||
return {
|
||||
ok: false,
|
||||
loggedIn: false,
|
||||
currentView: 'login',
|
||||
requiresLogin: true,
|
||||
mailboxEmail: '',
|
||||
};
|
||||
}
|
||||
break;
|
||||
}
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
const loginState = detectMail2925ViewState();
|
||||
log(`步骤 0:2925 准备执行登录,当前状态=${loginState.view},地址=${location.href}`, 'info');
|
||||
if (loginState.view === 'mailbox') {
|
||||
return {
|
||||
ok: true,
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: loginState.mailboxEmail || '',
|
||||
};
|
||||
}
|
||||
if (loginState.view === 'limit') {
|
||||
return {
|
||||
ok: false,
|
||||
loggedIn: false,
|
||||
currentView: 'limit',
|
||||
limitReached: true,
|
||||
limitMessage: loginState.limitMessage,
|
||||
};
|
||||
}
|
||||
if (!forceLogin && !allowLoginWhenOnLoginPage && loginState.view === 'login') {
|
||||
return {
|
||||
ok: false,
|
||||
loggedIn: false,
|
||||
currentView: 'login',
|
||||
requiresLogin: true,
|
||||
mailboxEmail: '',
|
||||
};
|
||||
}
|
||||
|
||||
const emailInput = findMail2925LoginEmailInput();
|
||||
const passwordInput = findMail2925LoginPasswordInput();
|
||||
const loginButton = findLoginButton();
|
||||
if (!emailInput || !passwordInput || !loginButton) {
|
||||
throw new Error('2925:未识别到可用的登录表单,请确认当前页面处于 2925 登录页。');
|
||||
}
|
||||
if (!email || !password) {
|
||||
throw new Error('2925:当前账号缺少邮箱或密码,无法自动登录。');
|
||||
}
|
||||
|
||||
await ensureAgreementChecked();
|
||||
fillInput(emailInput, email);
|
||||
await sleep(150);
|
||||
fillInput(passwordInput, password);
|
||||
await sleep(200);
|
||||
await sleep(1000);
|
||||
log(`步骤 0:2925 已定位到登录表单,准备点击“登录”,当前地址 ${location.href}`, 'info');
|
||||
simulateClick(loginButton);
|
||||
log(`步骤 0:2925 已点击“登录”,点击后地址 ${location.href}`, 'info');
|
||||
|
||||
const finalState = await waitForMail2925View('mailbox', 40000);
|
||||
log(`步骤 0:2925 登录等待结束,状态=${finalState.view},地址=${location.href}`, 'info');
|
||||
if (finalState.view !== 'mailbox') {
|
||||
throw new Error('2925:提交账号密码后未进入收件箱。');
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
usedCredentials: true,
|
||||
mailboxEmail: finalState.mailboxEmail || getMail2925DisplayedMailboxEmail() || '',
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePollEmail(step, payload) {
|
||||
await ensureSeenCodesSession(step, payload);
|
||||
const {
|
||||
@@ -521,10 +1045,17 @@ async function handlePollEmail(step, payload) {
|
||||
subjectFilters,
|
||||
maxAttempts,
|
||||
intervalMs,
|
||||
filterAfterTimestamp = 0,
|
||||
excludeCodes = [],
|
||||
strictChatGPTCodeOnly = false,
|
||||
targetEmail = '',
|
||||
mail2925MatchTargetEmail = false,
|
||||
} = payload || {};
|
||||
const excludedCodeSet = new Set(excludeCodes.filter(Boolean));
|
||||
const filterAfterMinute = normalizeMinuteTimestamp(Number(filterAfterTimestamp) || 0);
|
||||
if (typeof throwIfMail2925LimitReached === 'function') {
|
||||
throwIfMail2925LimitReached();
|
||||
}
|
||||
|
||||
log(`步骤 ${step}:开始轮询 2925 邮箱(最多 ${maxAttempts} 次)`);
|
||||
|
||||
@@ -544,6 +1075,9 @@ async function handlePollEmail(step, payload) {
|
||||
await returnToInbox();
|
||||
await refreshInbox();
|
||||
await sleep(2000);
|
||||
if (typeof throwIfMail2925LimitReached === 'function') {
|
||||
throwIfMail2925LimitReached();
|
||||
}
|
||||
initialItems = findMailItems();
|
||||
}
|
||||
|
||||
@@ -551,13 +1085,12 @@ async function handlePollEmail(step, payload) {
|
||||
throw new Error('2925 邮箱列表未加载完成,请确认当前已打开收件箱。');
|
||||
}
|
||||
|
||||
const knownMailIds = getCurrentMailIds(initialItems);
|
||||
log(`步骤 ${step}:邮件列表已加载,共 ${initialItems.length} 封邮件`);
|
||||
log(`步骤 ${step}:已记录当前 ${knownMailIds.size} 封旧邮件快照`);
|
||||
|
||||
const FALLBACK_AFTER = 3;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
if (typeof throwIfMail2925LimitReached === 'function') {
|
||||
throwIfMail2925LimitReached();
|
||||
}
|
||||
log(`步骤 ${step}:正在轮询 2925 邮箱,第 ${attempt}/${maxAttempts} 次`);
|
||||
|
||||
if (attempt > 1 || !initialLoadUsedRefresh) {
|
||||
@@ -568,23 +1101,12 @@ async function handlePollEmail(step, payload) {
|
||||
|
||||
const items = findMailItems();
|
||||
if (items.length > 0) {
|
||||
const useFallback = attempt > FALLBACK_AFTER;
|
||||
const newMailIds = new Set();
|
||||
|
||||
items.forEach((item, index) => {
|
||||
const itemId = getMailItemId(item, index);
|
||||
if (!knownMailIds.has(itemId)) {
|
||||
newMailIds.add(itemId);
|
||||
}
|
||||
});
|
||||
|
||||
for (let index = 0; index < items.length; index += 1) {
|
||||
const item = items[index];
|
||||
const itemId = getMailItemId(item, index);
|
||||
const isNewMail = newMailIds.has(itemId);
|
||||
const itemTimestamp = parseMailItemTimestamp(item);
|
||||
const itemMinute = normalizeMinuteTimestamp(itemTimestamp || 0);
|
||||
|
||||
if (!useFallback && !isNewMail) {
|
||||
if (filterAfterMinute && (!itemMinute || itemMinute < filterAfterMinute)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -592,9 +1114,21 @@ async function handlePollEmail(step, payload) {
|
||||
if (!matchesMailFilters(previewText, senderFilters, subjectFilters)) {
|
||||
continue;
|
||||
}
|
||||
const previewTargetState = mail2925MatchTargetEmail
|
||||
? getTargetEmailMatchState(previewText, targetEmail)
|
||||
: { matches: true, hasExplicitEmail: false };
|
||||
if (mail2925MatchTargetEmail && previewTargetState.hasExplicitEmail && !previewTargetState.matches) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const previewCode = extractVerificationCode(previewText, strictChatGPTCodeOnly);
|
||||
const openedText = await openMailAndDeleteAfterRead(item, step);
|
||||
const openedTargetState = mail2925MatchTargetEmail
|
||||
? getTargetEmailMatchState(openedText, targetEmail)
|
||||
: { matches: true, hasExplicitEmail: false };
|
||||
if (mail2925MatchTargetEmail && openedTargetState.hasExplicitEmail && !openedTargetState.matches) {
|
||||
continue;
|
||||
}
|
||||
const bodyCode = extractVerificationCode(openedText, strictChatGPTCodeOnly);
|
||||
const candidateCode = bodyCode || previewCode;
|
||||
|
||||
@@ -613,21 +1147,11 @@ async function handlePollEmail(step, payload) {
|
||||
|
||||
seenCodes.add(candidateCode);
|
||||
persistSeenCodes();
|
||||
const source = useFallback && !isNewMail
|
||||
? (bodyCode ? '回退匹配邮件正文' : '回退匹配邮件')
|
||||
: (bodyCode ? '新邮件正文' : '新邮件');
|
||||
const source = bodyCode ? '邮件正文' : '邮件预览';
|
||||
const timeLabel = itemTimestamp ? `,时间:${new Date(itemTimestamp).toLocaleString('zh-CN', { hour12: false })}` : '';
|
||||
log(`步骤 ${step}:已找到验证码:${candidateCode}(来源:${source}${timeLabel})`, 'ok');
|
||||
return { ok: true, code: candidateCode, emailTimestamp: Date.now() };
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
knownMailIds.add(getMailItemId(item, index));
|
||||
});
|
||||
}
|
||||
|
||||
if (attempt === FALLBACK_AFTER + 1) {
|
||||
log(`步骤 ${step}:连续 ${FALLBACK_AFTER} 次未发现新邮件,开始回退到首封匹配邮件`, 'warn');
|
||||
}
|
||||
|
||||
if (attempt < maxAttempts) {
|
||||
|
||||
+854
-135
File diff suppressed because it is too large
Load Diff
+262
-34
@@ -1,4 +1,4 @@
|
||||
// content/vps-panel.js — Content script for CPA panel (steps 1, 9)
|
||||
// content/vps-panel.js — Content script for CPA panel (steps 7, 10 / OAuth URL request)
|
||||
// Injected on: CPA panel (user-configured URL)
|
||||
//
|
||||
// Actual DOM structure (after login click):
|
||||
@@ -19,7 +19,10 @@
|
||||
// <div class="OAuthPage-module__callbackSection___8kA31">
|
||||
// <input class="input" placeholder="http://localhost:1455/auth/callback?code=...&state=...">
|
||||
// <button class="btn btn-secondary btn-sm"><span>提交回调 URL</span></button>
|
||||
// <div class="status-badge success">回调 URL 已提交,等待认证中...</div>
|
||||
// <div class="status-badge error">回调 URL 提交失败: ...</div>
|
||||
// </div>
|
||||
// <div class="status-badge">等待认证中... / 认证成功! / 认证失败: ...</div>
|
||||
// </div>
|
||||
// </div>
|
||||
|
||||
@@ -192,19 +195,19 @@ function isLocalhostOAuthCallbackUrl(rawUrl) {
|
||||
|
||||
function getStatusBadgeSelectors() {
|
||||
return [
|
||||
'#root > div > div > div > main > div > div > div > div > div:nth-child(1) > div > div.OAuthPage-module__cardContent___1sXLA > div.status-badge',
|
||||
'#root .OAuthPage-module__cardContent___1sXLA > .status-badge',
|
||||
'.OAuthPage-module__cardContent___1sXLA > .status-badge',
|
||||
'#root .OAuthPage-module__cardContent___1sXLA .status-badge',
|
||||
'[class*="cardContent"] .status-badge',
|
||||
'.status-badge',
|
||||
];
|
||||
}
|
||||
|
||||
function getStatusBadgeEntries() {
|
||||
const searchRoot = findCodexOAuthCard() || document;
|
||||
const seen = new Set();
|
||||
const entries = [];
|
||||
|
||||
for (const selector of getStatusBadgeSelectors()) {
|
||||
const candidates = document.querySelectorAll(selector);
|
||||
const candidates = searchRoot.querySelectorAll(selector);
|
||||
for (const candidate of candidates) {
|
||||
if (seen.has(candidate)) continue;
|
||||
seen.add(candidate);
|
||||
@@ -238,21 +241,63 @@ function normalizeStep9StatusText(statusText) {
|
||||
}
|
||||
|
||||
function isOAuthCallbackTimeoutFailure(statusText) {
|
||||
return /认证失败:\s*(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded)/i.test(statusText || '');
|
||||
return /(?:认证失败\s*[::]?\s*)?(?:Timeout waiting for OAuth callback|timeout of \d+ms exceeded|OAuth flow timed out)/i.test(statusText || '');
|
||||
}
|
||||
|
||||
function getStep10StatusBadgeLocation(element) {
|
||||
if (element?.closest?.('[class*="callbackSection"]')) {
|
||||
return 'callback';
|
||||
}
|
||||
if (element?.closest?.('[class*="cardContent"]')) {
|
||||
return 'main';
|
||||
}
|
||||
return 'page';
|
||||
}
|
||||
|
||||
function isStep10CallbackSubmittedStatus(statusText) {
|
||||
const text = normalizeStep9StatusText(statusText);
|
||||
return /回调\s*url\s*已提交.*等待认证中/i.test(text)
|
||||
|| /callback\s*url\s*submitted.*waiting/i.test(text);
|
||||
}
|
||||
|
||||
function isStep10CallbackFailureText(statusText) {
|
||||
const text = normalizeStep9StatusText(statusText);
|
||||
if (!text) return false;
|
||||
return /(?:回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[::,,]?\s*/i.test(text)
|
||||
|| /请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(text);
|
||||
}
|
||||
|
||||
function isStep10MainWaitingStatus(statusText) {
|
||||
const text = normalizeStep9StatusText(statusText);
|
||||
return /等待认证中/i.test(text);
|
||||
}
|
||||
|
||||
function isStep10MainFailureText(statusText) {
|
||||
const text = normalizeStep9StatusText(statusText);
|
||||
if (!text) return false;
|
||||
if (/^认证失败\s*[::]?\s*/i.test(text)) return true;
|
||||
return /bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out|request failed with status code \d+|timeout of \d+ms exceeded|network error|failed to fetch/i.test(text);
|
||||
}
|
||||
|
||||
function isStep9FailureText(statusText) {
|
||||
const text = normalizeStep9StatusText(statusText);
|
||||
if (!text) return false;
|
||||
if (isOAuthCallbackTimeoutFailure(text)) return true;
|
||||
if (isStep10CallbackFailureText(text)) return true;
|
||||
if (isStep10MainFailureText(text)) return true;
|
||||
if (typeof isRecoverableStep9AuthFailure === 'function' && isRecoverableStep9AuthFailure(text)) {
|
||||
return true;
|
||||
}
|
||||
return /回调\s*url\s*提交失败|callback\s*url\s*submit\s*failed|oauth flow is not pending/i.test(text);
|
||||
return /callback\s*url\s*submit\s*failed|oauth flow is not pending/i.test(text);
|
||||
}
|
||||
|
||||
function isStep9SuccessStatus(statusText) {
|
||||
return STEP9_SUCCESS_STATUSES.has(normalizeStep9StatusText(statusText));
|
||||
const text = normalizeStep9StatusText(statusText);
|
||||
if (!text) return false;
|
||||
return STEP9_SUCCESS_STATUSES.has(text)
|
||||
|| /^认证成功[!!]?$/i.test(text)
|
||||
|| /^Authentication successful!?$/i.test(text)
|
||||
|| /^Аутентификация успешна!?$/i.test(text);
|
||||
}
|
||||
|
||||
function isStep9SuccessLikeStatus(statusText) {
|
||||
@@ -340,6 +385,7 @@ function createStep9Entry(candidate, selector) {
|
||||
return {
|
||||
element: candidate,
|
||||
selector,
|
||||
location: getStep10StatusBadgeLocation(candidate),
|
||||
visible: isVisibleElement(candidate),
|
||||
text: normalizeStep9StatusText(candidate?.textContent || ''),
|
||||
className,
|
||||
@@ -364,36 +410,72 @@ function getStep9PageErrorSelectors() {
|
||||
}
|
||||
|
||||
function getStep9PageErrorEntries() {
|
||||
const cardRoot = findCodexOAuthCard();
|
||||
const searchRoots = [cardRoot, document].filter(Boolean);
|
||||
const seen = new Set();
|
||||
const entries = [];
|
||||
|
||||
for (const root of searchRoots) {
|
||||
for (const selector of getStep9PageErrorSelectors()) {
|
||||
const candidates = document.querySelectorAll(selector);
|
||||
const candidates = root.querySelectorAll(selector);
|
||||
for (const candidate of candidates) {
|
||||
if (seen.has(candidate)) continue;
|
||||
seen.add(candidate);
|
||||
if (!isVisibleElement(candidate)) continue;
|
||||
|
||||
const entry = createStep9Entry(candidate, selector);
|
||||
if (/\bstatus-badge\b/i.test(entry.className)) continue;
|
||||
if (!isStep9FailureText(entry.text)) continue;
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
function formatStep10StatusSummaryValue(text, emptyText = '无') {
|
||||
return text ? `"${getInlineTextSnippet(text, 80)}"` : emptyText;
|
||||
}
|
||||
|
||||
function isStep10BrowserSwitchRequiredConflict(diagnostics = {}) {
|
||||
return Boolean(diagnostics?.hasExactSuccessVisibleBadge)
|
||||
&& /请更新\s*cli\s*proxy\s*api\s*或检查连接/i.test(String(diagnostics?.callbackFailureText || ''));
|
||||
}
|
||||
|
||||
function getStep10BrowserSwitchRequiredMessage(diagnostics = {}) {
|
||||
const callbackFailureText = normalizeStep9StatusText(diagnostics?.callbackFailureText || '');
|
||||
return [
|
||||
'检测到 CPA 页面同时显示“认证成功”和“回调 URL 提交失败: 请更新CLI Proxy API或检查连接”。',
|
||||
'这类冲突状态通常通过更换浏览器可以解决,请更换浏览器后重新进行注册登录。',
|
||||
callbackFailureText ? `面板原文:${callbackFailureText}` : '',
|
||||
].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSnippet = '') {
|
||||
const visibleEntries = entries.filter((entry) => entry.visible);
|
||||
const successLikeEntries = visibleEntries.filter((entry) => isStep9SuccessLikeStatus(entry.text));
|
||||
const exactSuccessEntries = visibleEntries.filter((entry) => isStep9SuccessStatus(entry.text) && !entry.hasErrorVisualSignal);
|
||||
const failureEntries = visibleEntries.filter((entry) => isStep9FailureText(entry.text));
|
||||
const callbackEntries = visibleEntries.filter((entry) => entry.location === 'callback');
|
||||
const mainEntries = visibleEntries.filter((entry) => entry.location === 'main');
|
||||
const successLikeEntries = mainEntries.filter((entry) => isStep9SuccessLikeStatus(entry.text));
|
||||
const exactSuccessEntries = mainEntries.filter((entry) => isStep9SuccessStatus(entry.text) && !entry.hasErrorVisualSignal);
|
||||
const callbackSubmittedEntries = callbackEntries.filter((entry) => isStep10CallbackSubmittedStatus(entry.text) && !entry.hasErrorVisualSignal);
|
||||
const callbackFailureEntries = callbackEntries.filter((entry) => isStep10CallbackFailureText(entry.text));
|
||||
const mainWaitingEntries = mainEntries.filter((entry) => isStep10MainWaitingStatus(entry.text) && !entry.hasErrorVisualSignal);
|
||||
const mainFailureEntries = mainEntries.filter((entry) => isStep10MainFailureText(entry.text));
|
||||
const failureEntries = [...callbackFailureEntries, ...mainFailureEntries];
|
||||
const errorStyledEntries = visibleEntries.filter((entry) => entry.hasErrorVisualSignal);
|
||||
const allFailureEntries = [...failureEntries, ...pageErrorEntries];
|
||||
const decisiveFailureEntry = allFailureEntries[0] || null;
|
||||
const selectedEntry = decisiveFailureEntry || exactSuccessEntries[0] || visibleEntries[0] || null;
|
||||
const selectedEntry = decisiveFailureEntry
|
||||
|| exactSuccessEntries[0]
|
||||
|| callbackSubmittedEntries[0]
|
||||
|| mainWaitingEntries[0]
|
||||
|| visibleEntries[0]
|
||||
|| null;
|
||||
const selectedText = selectedEntry?.text || '';
|
||||
const visibleSummary = summarizeStatusBadgeEntries(visibleEntries);
|
||||
const callbackSummary = summarizeStatusBadgeEntries(callbackEntries);
|
||||
const mainSummary = summarizeStatusBadgeEntries(mainEntries);
|
||||
const successLikeSummary = summarizeStatusBadgeEntries(successLikeEntries);
|
||||
const exactSuccessSummary = summarizeStatusBadgeEntries(exactSuccessEntries);
|
||||
const failureSummary = summarizeStatusBadgeEntries(failureEntries);
|
||||
@@ -406,10 +488,20 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
|
||||
selectedText,
|
||||
exactSuccessText: exactSuccessEntries[0]?.text || '',
|
||||
failureText: decisiveFailureEntry?.text || '',
|
||||
failureSource: decisiveFailureEntry?.location || (pageErrorEntries.length ? 'page' : ''),
|
||||
visibleCount: visibleEntries.length,
|
||||
visibleSummary,
|
||||
callbackSummary,
|
||||
mainSummary,
|
||||
callbackStatusText: callbackEntries[0]?.text || '',
|
||||
callbackSubmittedText: callbackSubmittedEntries[0]?.text || '',
|
||||
callbackFailureText: callbackFailureEntries[0]?.text || '',
|
||||
mainStatusText: mainEntries[0]?.text || '',
|
||||
mainWaitingText: mainWaitingEntries[0]?.text || '',
|
||||
mainFailureText: mainFailureEntries[0]?.text || '',
|
||||
hasSuccessLikeVisibleBadge: successLikeEntries.length > 0,
|
||||
hasExactSuccessVisibleBadge: exactSuccessEntries.length > 0,
|
||||
hasCallbackSubmittedBadge: callbackSubmittedEntries.length > 0,
|
||||
hasFailureVisibleBadge: allFailureEntries.length > 0,
|
||||
hasErrorStyledVisibleBadge: errorStyledEntries.length > 0,
|
||||
successLikeSummary,
|
||||
@@ -422,6 +514,8 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
|
||||
selectedText,
|
||||
visibleCount: visibleEntries.length,
|
||||
visibleSummary,
|
||||
callbackSummary,
|
||||
mainSummary,
|
||||
successLikeSummary,
|
||||
exactSuccessSummary,
|
||||
failureSummary,
|
||||
@@ -429,8 +523,8 @@ function buildStep9StatusDiagnostics(entries = [], pageErrorEntries = [], pageSn
|
||||
errorStyledSummary,
|
||||
}),
|
||||
summary: selectedText
|
||||
? `当前聚焦状态="${getInlineTextSnippet(selectedText, 80)}";可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix}`
|
||||
: `当前未选中任何可见状态徽标;可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
|
||||
? `当前聚焦状态=${formatStep10StatusSummaryValue(selectedText)};回调提示=${formatStep10StatusSummaryValue(callbackEntries[0]?.text || '')};主状态=${formatStep10StatusSummaryValue(mainEntries[0]?.text || '')};页面错误=${formatStep10StatusSummaryValue(pageErrorEntries[0]?.text || '')};可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix}`
|
||||
: `当前未选中任何可见状态;回调提示=${formatStep10StatusSummaryValue(callbackEntries[0]?.text || '')};主状态=${formatStep10StatusSummaryValue(mainEntries[0]?.text || '')};页面错误=${formatStep10StatusSummaryValue(pageErrorEntries[0]?.text || '')};可见徽标 ${visibleEntries.length} 个:${visibleSummary}${extraFailureSuffix}${errorStyledSuffix};页面片段="${getInlineTextSnippet(pageSnippet, 120)}"`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -452,11 +546,129 @@ function getStatusBadgeText() {
|
||||
return diagnostics.selectedText;
|
||||
}
|
||||
|
||||
function extractStep10FailureDetail(statusText, sourceKind = '') {
|
||||
const text = normalizeStep9StatusText(statusText);
|
||||
if (!text) return '';
|
||||
if (sourceKind === 'callback' || isStep10CallbackFailureText(text)) {
|
||||
return text.replace(/^(?:回调\s*url\s*提交失败|回调url提交失败|提交回调失败)\s*[::,,]?\s*/i, '').trim();
|
||||
}
|
||||
if (sourceKind === 'main' || isStep10MainFailureText(text)) {
|
||||
return text.replace(/^认证失败\s*[::]?\s*/i, '').trim();
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function explainStep10Failure(statusText, sourceKind = 'unknown') {
|
||||
const rawText = normalizeStep9StatusText(statusText);
|
||||
const detail = extractStep10FailureDetail(rawText, sourceKind) || rawText;
|
||||
const phaseLabel = sourceKind === 'callback'
|
||||
? '回调提交阶段'
|
||||
: sourceKind === 'main'
|
||||
? '认证结果阶段'
|
||||
: '页面状态阶段';
|
||||
|
||||
const rules = [
|
||||
{
|
||||
code: 'callback_submit_api_unavailable',
|
||||
pattern: /请更新\s*cli\s*proxy\s*api\s*或检查连接/i,
|
||||
message: 'CPA 面板无法把回调提交给后台,通常是 CLI Proxy API 版本过旧、管理接口未启动,或当前面板与后端连接异常。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_state_expired',
|
||||
pattern: /unknown or expired state/i,
|
||||
message: '当前 OAuth 会话在 CPA 中已不存在或已过期,通常是使用了旧回调链接、刷新过新的授权链接后仍提交旧链接,或 CPA 刚重启过。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_not_pending',
|
||||
pattern: /oauth flow is not pending/i,
|
||||
message: '当前 OAuth 会话已经不在等待状态,通常是重复提交、提交过慢,或这轮认证此前已经结束。',
|
||||
},
|
||||
{
|
||||
code: 'callback_state_invalid',
|
||||
pattern: /invalid state|state is required|missing_state/i,
|
||||
message: '回调链接里的 state 缺失或无效,通常是复制了不完整的 localhost 回调链接,或提交了不属于这一轮的旧链接。',
|
||||
},
|
||||
{
|
||||
code: 'callback_missing_result',
|
||||
pattern: /code or error is required/i,
|
||||
message: '回调链接里既没有授权码,也没有错误信息,通常是复制的 localhost 回调链接不完整。',
|
||||
},
|
||||
{
|
||||
code: 'callback_invalid_url',
|
||||
pattern: /invalid redirect_url/i,
|
||||
message: '提交给 CPA 的回调链接格式无法解析,通常是粘贴内容不完整、带了多余字符,或并不是 localhost OAuth 回调地址。',
|
||||
},
|
||||
{
|
||||
code: 'callback_provider_mismatch',
|
||||
pattern: /provider does not match state/i,
|
||||
message: '这条回调不属于当前这次 Codex OAuth,会话与回调来源对不上,通常是混用了其他轮次或其他提供方的回调。',
|
||||
},
|
||||
{
|
||||
code: 'callback_persist_failed',
|
||||
pattern: /failed to persist oauth callback/i,
|
||||
message: 'CPA 已收到回调,但无法把回调结果写入本地缓存文件,通常是认证目录权限、磁盘或运行环境异常。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_bad_request',
|
||||
pattern: /^bad request$/i,
|
||||
message: 'CPA 已收到回调,但 OpenAI OAuth 回调本身返回了错误。常见于用户取消授权、请求过期,或这条回调已经失效。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_state_mismatch',
|
||||
pattern: /state code error/i,
|
||||
message: 'CPA 校验到回调里的 state 与当前 OAuth 会话不一致,通常是步骤 1 已刷新过新的授权链接,但步骤 10 仍提交旧回调。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_code_exchange_failed',
|
||||
pattern: /failed to exchange authorization code for tokens/i,
|
||||
message: 'CPA 已收到授权码,但向 OpenAI 交换令牌失败。常见于 CPA 到 OpenAI 的网络或代理异常,或授权码已过期。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_token_save_failed',
|
||||
pattern: /failed to save authentication tokens/i,
|
||||
message: 'CPA 已完成认证,但保存认证文件失败。常见于认证目录权限、磁盘写入,或 post-auth hook 异常。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_callback_timeout',
|
||||
pattern: /timeout waiting for oauth callback|oauth flow timed out/i,
|
||||
message: 'CPA 长时间没有把这轮 OAuth 流程走完。常见于提交太晚、面板轮询异常,或后端状态没有及时刷新。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_http_timeout',
|
||||
pattern: /timeout of \d+ms exceeded/i,
|
||||
message: 'CPA 面板在请求后台接口时超时,通常是 CLI Proxy API 响应过慢、接口未启动,或网络连接不稳定。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_http_status_error',
|
||||
pattern: /request failed with status code \d+/i,
|
||||
message: 'CPA 面板请求后台接口时收到了异常 HTTP 状态码,通常是接口异常、反向代理配置错误,或当前会话已失效。',
|
||||
},
|
||||
{
|
||||
code: 'oauth_network_error',
|
||||
pattern: /network error|failed to fetch/i,
|
||||
message: 'CPA 面板与后台通信失败,通常是网络不通、管理接口未启动,或浏览器当前连接已断开。',
|
||||
},
|
||||
];
|
||||
|
||||
const matchedRule = rules.find((rule) => rule.pattern.test(detail) || rule.pattern.test(rawText));
|
||||
const message = matchedRule
|
||||
? matchedRule.message
|
||||
: `CPA 在${phaseLabel}返回了未归类的失败,请结合面板原文进一步排查。`;
|
||||
|
||||
return {
|
||||
code: matchedRule?.code || 'oauth_unknown_failure',
|
||||
phaseLabel,
|
||||
rawText,
|
||||
detail,
|
||||
userMessage: `CPA 在${phaseLabel}返回失败:${message} 面板原文:${rawText}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS) {
|
||||
const start = Date.now();
|
||||
let lastDiagnosticsSignature = '';
|
||||
let lastHeartbeatLoggedAt = 0;
|
||||
let lastSuccessLikeMismatchSignature = '';
|
||||
let lastCallbackSubmittedSignature = '';
|
||||
let lastSuccessFailureConflictSignature = '';
|
||||
|
||||
while (Date.now() - start < timeout) {
|
||||
@@ -475,26 +687,28 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
|
||||
console.log(LOG_PREFIX, '[Step 9] still waiting for success badge', diagnostics);
|
||||
}
|
||||
|
||||
if (diagnostics.hasSuccessLikeVisibleBadge && !diagnostics.hasExactSuccessVisibleBadge) {
|
||||
const mismatchSignature = JSON.stringify({
|
||||
selectedText: diagnostics.selectedText,
|
||||
successLikeSummary: diagnostics.successLikeSummary,
|
||||
visibleSummary: diagnostics.visibleSummary,
|
||||
errorStyledSummary: diagnostics.errorStyledSummary,
|
||||
if (diagnostics.hasCallbackSubmittedBadge && !diagnostics.hasExactSuccessVisibleBadge) {
|
||||
const callbackSubmittedSignature = JSON.stringify({
|
||||
callbackStatusText: diagnostics.callbackStatusText,
|
||||
mainStatusText: diagnostics.mainStatusText,
|
||||
});
|
||||
if (mismatchSignature !== lastSuccessLikeMismatchSignature) {
|
||||
lastSuccessLikeMismatchSignature = mismatchSignature;
|
||||
const errorStyledSuffix = diagnostics.hasErrorStyledVisibleBadge
|
||||
? `;错误样式徽标:${diagnostics.errorStyledSummary}`
|
||||
: '';
|
||||
if (callbackSubmittedSignature !== lastCallbackSubmittedSignature) {
|
||||
lastCallbackSubmittedSignature = callbackSubmittedSignature;
|
||||
log(
|
||||
`步骤 10:检测到“认证成功”相关徽标,但未命中精确成功条件。当前聚焦="${getInlineTextSnippet(diagnostics.selectedText || '(空)', 80)}";成功相关徽标:${diagnostics.successLikeSummary}${errorStyledSuffix}`,
|
||||
'warn'
|
||||
`步骤 10:CPA 已接受 localhost 回调,正在等待后台完成认证。回调提示=${formatStep10StatusSummaryValue(diagnostics.callbackStatusText)};主状态=${formatStep10StatusSummaryValue(diagnostics.mainStatusText)}`,
|
||||
'info'
|
||||
);
|
||||
console.warn(LOG_PREFIX, '[Step 9] success-like badge detected without exact match', diagnostics);
|
||||
console.info(LOG_PREFIX, '[Step 9] callback accepted and waiting for auth completion', diagnostics);
|
||||
}
|
||||
}
|
||||
|
||||
if (isStep10BrowserSwitchRequiredConflict(diagnostics)) {
|
||||
const browserSwitchMessage = getStep10BrowserSwitchRequiredMessage(diagnostics);
|
||||
log(`步骤 10:${browserSwitchMessage}`, 'error');
|
||||
console.error(LOG_PREFIX, '[Step 9] browser-switch conflict detected', diagnostics);
|
||||
throw new Error(`BROWSER_SWITCH_REQUIRED::${browserSwitchMessage}`);
|
||||
}
|
||||
|
||||
if (diagnostics.hasExactSuccessVisibleBadge && diagnostics.hasFailureVisibleBadge) {
|
||||
const conflictSignature = JSON.stringify({
|
||||
exactSuccessSummary: diagnostics.exactSuccessSummary,
|
||||
@@ -515,10 +729,11 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
|
||||
}
|
||||
|
||||
if (diagnostics.failureText) {
|
||||
const failureExplanation = explainStep10Failure(diagnostics.failureText, diagnostics.failureSource || 'unknown');
|
||||
if (isOAuthCallbackTimeoutFailure(diagnostics.failureText)) {
|
||||
throw new Error(`STEP9_OAUTH_TIMEOUT::${diagnostics.failureText}`);
|
||||
throw new Error(`STEP9_OAUTH_TIMEOUT::${failureExplanation.userMessage}`);
|
||||
}
|
||||
throw new Error(`STEP9_OAUTH_RETRY::${diagnostics.failureText}`);
|
||||
throw new Error(`STEP9_OAUTH_RETRY::${failureExplanation.userMessage}`);
|
||||
}
|
||||
if (diagnostics.exactSuccessText) {
|
||||
return diagnostics.exactSuccessText;
|
||||
@@ -530,10 +745,18 @@ async function waitForExactSuccessBadge(timeout = STEP9_SUCCESS_BADGE_TIMEOUT_MS
|
||||
const finalText = finalDiagnostics.failureText || finalDiagnostics.selectedText;
|
||||
const diagnosticsSuffix = ` 当前诊断:${finalDiagnostics.summary}`;
|
||||
if (isOAuthCallbackTimeoutFailure(finalText)) {
|
||||
throw new Error(`STEP9_OAUTH_TIMEOUT::${finalText}${diagnosticsSuffix}`);
|
||||
const failureExplanation = explainStep10Failure(finalText, finalDiagnostics.failureSource || 'main');
|
||||
throw new Error(`STEP9_OAUTH_TIMEOUT::${failureExplanation.userMessage}${diagnosticsSuffix}`);
|
||||
}
|
||||
if (isStep9FailureText(finalText)) {
|
||||
throw new Error(`STEP9_OAUTH_RETRY::${finalText}${diagnosticsSuffix}`);
|
||||
const failureExplanation = explainStep10Failure(finalText, finalDiagnostics.failureSource || 'unknown');
|
||||
throw new Error(`STEP9_OAUTH_RETRY::${failureExplanation.userMessage}${diagnosticsSuffix}`);
|
||||
}
|
||||
if (finalDiagnostics.hasCallbackSubmittedBadge || finalDiagnostics.mainWaitingText) {
|
||||
throw new Error(
|
||||
'STEP9_OAUTH_TIMEOUT::CPA 已接受回调,但 120 秒内仍未进入认证成功状态。通常是 CPA 后台处理过慢、面板轮询异常,或 CPA 到 OpenAI 的网络/代理存在问题。'
|
||||
+ diagnosticsSuffix
|
||||
);
|
||||
}
|
||||
throw new Error(finalText
|
||||
? `CPA 面板状态未进入成功状态,当前为“${finalText}”。${diagnosticsSuffix}`
|
||||
@@ -583,6 +806,11 @@ function findCodexOAuthHeader() {
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function findCodexOAuthCard() {
|
||||
const header = findCodexOAuthHeader();
|
||||
return header?.closest('.card, [class*="card"]') || header || null;
|
||||
}
|
||||
|
||||
function findOAuthCardLoginButton(header) {
|
||||
const card = header?.closest('.card, [class*="card"]') || header?.parentElement || document;
|
||||
const candidates = card.querySelectorAll('button.btn.btn-primary, button.btn-primary, button.btn');
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 70 KiB |
+190
@@ -0,0 +1,190 @@
|
||||
本教程用于说明相关项目地址、扩展更新方式,以及 `Clash Verge` 的 `🔁 非港轮询` 配置方法。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 需要拉取并部署 `cpa` 或 `sub2api` 项目
|
||||
- 已经安装本扩展,想更新到最新版本
|
||||
- 想用更方便的方式长期同步扩展更新
|
||||
- 需要在 `Clash Verge` 中启用 `🔁 非港轮询`
|
||||
|
||||
## 准备内容
|
||||
|
||||
- 可以访问 `GitHub`
|
||||
- 已安装好的扩展文件夹
|
||||
- 可以打开浏览器的 `扩展程序管理` 页面
|
||||
- 如需部署 `cpa`,部署环境必须可以访问 `OpenAI`
|
||||
- 已安装 `Clash Verge`,并已导入可用订阅
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 第一部分:相关项目地址与部署说明
|
||||
|
||||
1. 查看项目地址
|
||||
`cpa` 项目地址:<https://github.com/router-for-me/CLIProxyAPI>
|
||||
`sub2api` 项目地址:<https://github.com/Wei-Shaw/sub2api>
|
||||
|
||||
2. 拉取项目到本地
|
||||
先将你需要的项目拉取到本地目录。
|
||||
可以使用 `git clone`,也可以直接下载项目压缩包后解压。
|
||||
|
||||
3. 让 AI 直接协助部署
|
||||
部署教程这里不单独展开。
|
||||
将项目拉取下来后,直接让 AI 帮你部署即可。
|
||||
|
||||
4. 确认 `cpa` 的部署环境
|
||||
如果你部署的是 `cpa`,部署所在环境必须可以访问 `OpenAI`。
|
||||
否则可能会出现第十步显示认证成功,但实际上没有生成认证文件的情况。
|
||||
|
||||
### 第二部分:更新扩展
|
||||
|
||||
1. 使用 `GitHub Desktop` 更新
|
||||
先安装 `GitHub Desktop`。
|
||||
把当前扩展仓库交给 `GitHub Desktop` 管理后,之后就可以随时更新。
|
||||
`GitHub Desktop` 的具体使用方法本教程不展开,需要时可直接询问豆包。
|
||||
|
||||
2. 使用 `git pull` 更新(最方便,最推荐)
|
||||
先在本地安装 `git`。
|
||||
打开终端后执行 `cd 扩展文件夹路径` 进入当前扩展目录。
|
||||
接着执行 `git pull` 拉取最新更新。
|
||||
这是最方便、最推荐的更新方式。
|
||||
|
||||
3. 手动下载最新版本覆盖更新
|
||||
点击左上角的版本号或 `更新` 按钮。
|
||||
下载最新版本到本地后,直接覆盖现有扩展文件夹即可。
|
||||
|
||||
4. 更新完成后重新加载扩展
|
||||
不论使用哪种更新方式,更新完成后都必须打开浏览器的 `扩展程序管理` 页面。
|
||||
找到该扩展后,手动点击一次 `重新加载`。
|
||||
这一步一定要做,否则浏览器可能仍在使用旧版本。
|
||||
|
||||
### 第三部分:配置 `Clash Verge` 的 `🔁 非港轮询`
|
||||
|
||||
#### 第一步:添加扩展脚本
|
||||
|
||||
1. 打开 `Clash Verge`,进入左侧的 `订阅`(`Profiles`)界面。
|
||||
2. 在右上角或对应位置找到并双击打开 `全局扩展脚本`(`Merge/Script`)。
|
||||
3. 将里面的内容全部清空,替换为下方脚本代码。
|
||||
4. 点击保存,使用右上角保存按钮或按 `Ctrl+S`。
|
||||
|
||||
💻 脚本代码(如果遇到格式错误,可让豆包帮你修复后再粘贴)
|
||||
|
||||
```javascript
|
||||
function uniqPrepend(arr, items) {
|
||||
if (!Array.isArray(arr)) arr = [];
|
||||
for (var i = items.length - 1; i >= 0; i--) {
|
||||
var item = items[i];
|
||||
var exists = false;
|
||||
for (var j = 0; j < arr.length; j++) {
|
||||
if (arr[j] === item) {
|
||||
exists = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!exists) arr.unshift(item);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
|
||||
function upsertGroup(groups, group) {
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
if (groups[i] && groups[i].name === group.name) {
|
||||
groups[i] = group;
|
||||
return groups;
|
||||
}
|
||||
}
|
||||
groups.unshift(group);
|
||||
return groups;
|
||||
}
|
||||
|
||||
function main(config, profileName) {
|
||||
if (!config) return config;
|
||||
|
||||
if (!Array.isArray(config["proxy-groups"])) {
|
||||
config["proxy-groups"] = [];
|
||||
}
|
||||
|
||||
var groups = config["proxy-groups"];
|
||||
var LB_NAME = "🔁 非港轮询";
|
||||
|
||||
var excludeRegex =
|
||||
"(?i)(" +
|
||||
"香港|hong[ -]?kong|\\bhk\\b|\\bhkg\\b|🇭🇰" +
|
||||
"|剩余流量|套餐到期|下次重置剩余|重置剩余|到期时间|流量重置" +
|
||||
"|traffic|expire|expiration|subscription|subscribe|reset|plan" +
|
||||
")";
|
||||
|
||||
groups = upsertGroup(groups, {
|
||||
name: LB_NAME,
|
||||
type: "load-balance",
|
||||
strategy: "round-robin",
|
||||
"include-all-proxies": true,
|
||||
"exclude-filter": excludeRegex,
|
||||
url: "https://www.gstatic.com/generate_204",
|
||||
interval: 300,
|
||||
lazy: true,
|
||||
"expected-status": 204
|
||||
});
|
||||
|
||||
var injected = false;
|
||||
var entryNameRegex = /节点选择|代理|Proxy|PROXY|默认|GLOBAL|全局|选择/i;
|
||||
|
||||
for (var i = 0; i < groups.length; i++) {
|
||||
var g = groups[i];
|
||||
if (!g || g.type !== "select") continue;
|
||||
|
||||
if (entryNameRegex.test(g.name || "")) {
|
||||
if (!Array.isArray(g.proxies)) g.proxies = [];
|
||||
g.proxies = uniqPrepend(g.proxies, [LB_NAME]);
|
||||
injected = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!injected) {
|
||||
for (var k = 0; k < groups.length; k++) {
|
||||
var g2 = groups[k];
|
||||
if (g2 && g2.type === "select") {
|
||||
if (!Array.isArray(g2.proxies)) g2.proxies = [];
|
||||
g2.proxies = uniqPrepend(g2.proxies, [LB_NAME]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config["proxy-groups"] = groups;
|
||||
return config;
|
||||
}
|
||||
```
|
||||
|
||||
#### 第二步:应用设置
|
||||
|
||||
1. 切换到左侧的 `代理`(`Proxies`)界面,也就是首页。
|
||||
2. 在顶部的分类中,通常会看到 `节点选择`、`Proxy` 或 `当前节点` 之类的分组。
|
||||
3. 在对应下拉框中选择 `🔁 非港轮询`。
|
||||
4. 确认右上角的 `代理模式`(`Mode`)已经设置为 `规则模式`(`Rule`)。
|
||||
5. 确认左侧 `设置` 中的 `系统代理`(`System Proxy`)已经开启。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 为什么第十步显示认证成功,但没有认证文件?
|
||||
|
||||
这通常是因为 `cpa` 部署所在环境无法访问 `OpenAI`。请先确认部署环境可以正常访问 `OpenAI`,然后再重新执行相关认证步骤。
|
||||
|
||||
### 为什么更新完扩展后还是旧版本?
|
||||
|
||||
如果你只是替换了本地文件,但没有去浏览器的 `扩展程序管理` 页面点击 `重新加载`,浏览器不会立即启用新版本。请更新完成后手动重新加载一次该扩展。
|
||||
|
||||
### `git pull` 提示不是 Git 仓库怎么办?
|
||||
|
||||
这通常说明你进入的不是正确目录,或者当前文件夹不是通过 `git` 管理的版本。此时可以改用 `GitHub Desktop`,或者直接使用手动下载覆盖的方式更新。
|
||||
|
||||
### 为什么没有看到 `🔁 非港轮询`?
|
||||
|
||||
请先确认脚本已经完整粘贴并保存,然后回到 `代理` 页面重新查看。若仍未出现,请继续确认当前订阅可用、`代理模式` 为 `规则模式`,并且 `系统代理` 已开启。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 不论使用哪种方式更新扩展,更新完成后都必须在浏览器的 `扩展程序管理` 页面重新加载一次该扩展。
|
||||
- 如需部署 `cpa`,请先确认部署环境可以访问 `OpenAI`,否则可能出现第十步认证成功但没有认证文件的问题。
|
||||
- 如果你长期更新扩展,优先使用 `git pull`,这是最方便、最推荐的方式。
|
||||
- 使用手动覆盖更新时,请直接覆盖当前扩展文件夹,避免同时保留多份不同版本的目录。
|
||||
- 在 `Clash Verge` 中粘贴脚本时,请先清空旧内容,再完整粘贴新代码并保存。
|
||||
@@ -0,0 +1,312 @@
|
||||
# 使用教程书写模板
|
||||
|
||||
本文件用于约束 AI 生成“账号贡献站 / Codex 注册扩展”使用教程时的 Markdown 格式。
|
||||
|
||||
使用方式:
|
||||
|
||||
- 先把这份文件发给 AI
|
||||
- 再告诉 AI 本次要写哪一部分教程
|
||||
- 让 AI 严格按本文格式输出
|
||||
- 输出结果直接复制到管理端“使用教程”编辑区
|
||||
|
||||
---
|
||||
|
||||
## 1. 教程用途
|
||||
|
||||
使用教程用于说明:
|
||||
|
||||
- 页面怎么用
|
||||
- 上传文件怎么准备
|
||||
- 上传步骤是什么
|
||||
- 常见错误怎么理解
|
||||
- 注意事项有哪些
|
||||
|
||||
教程不是公告,所以要:
|
||||
|
||||
- 结构完整
|
||||
- 步骤清楚
|
||||
- 能让第一次接触的人看懂
|
||||
|
||||
---
|
||||
|
||||
## 2. 教程总体要求
|
||||
|
||||
AI 生成教程时必须满足:
|
||||
|
||||
1. 使用标准 Markdown
|
||||
2. 不使用 HTML
|
||||
3. 章节分明
|
||||
4. 按用户实际操作顺序来写
|
||||
5. 重点步骤必须编号
|
||||
6. 文件名、路径、接口、按钮名要用反引号包裹
|
||||
7. 语言要清楚,不要写废话
|
||||
|
||||
---
|
||||
|
||||
## 3. 标准结构
|
||||
|
||||
教程建议按这个结构输出:
|
||||
|
||||
```md
|
||||
# 教程标题
|
||||
|
||||
一句话说明这份教程是教什么的。
|
||||
|
||||
## 适用场景
|
||||
|
||||
- 场景 1
|
||||
- 场景 2
|
||||
|
||||
## 准备内容
|
||||
|
||||
- 需要准备的内容 1
|
||||
- 需要准备的内容 2
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 第一步:...
|
||||
|
||||
说明。
|
||||
|
||||
### 第二步:...
|
||||
|
||||
说明。
|
||||
|
||||
### 第三步:...
|
||||
|
||||
说明。
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 问题 1
|
||||
|
||||
说明。
|
||||
|
||||
### 问题 2
|
||||
|
||||
说明。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 注意项 1
|
||||
- 注意项 2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 标题规范
|
||||
|
||||
标题必须直接说清主题。
|
||||
|
||||
推荐示例:
|
||||
|
||||
- `# 账号贡献页使用教程`
|
||||
- `# 如何准备可上传的 JSON 文件`
|
||||
- `# 账号贡献上传操作说明`
|
||||
|
||||
不推荐:
|
||||
|
||||
- `# 使用方法`
|
||||
- `# 教你怎么弄`
|
||||
- `# 说明一下`
|
||||
|
||||
---
|
||||
|
||||
## 5. 正文写法规范
|
||||
|
||||
### 5.1 先讲适用场景
|
||||
|
||||
教程开头要先告诉用户:
|
||||
|
||||
- 这份教程适合谁
|
||||
- 能解决什么问题
|
||||
|
||||
示例:
|
||||
|
||||
```md
|
||||
本教程适用于首次使用账号贡献页的用户,帮助你完成文件准备、上传和结果查看。
|
||||
```
|
||||
|
||||
### 5.2 步骤必须按顺序写
|
||||
|
||||
不要跳步,不要先写报错再写操作。
|
||||
|
||||
推荐:
|
||||
|
||||
```md
|
||||
## 操作步骤
|
||||
|
||||
### 第一步:准备 JSON 文件
|
||||
|
||||
...
|
||||
|
||||
### 第二步:打开贡献页面
|
||||
|
||||
...
|
||||
|
||||
### 第三步:填写昵称并上传
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
### 5.3 命令、按钮、文件名要加反引号
|
||||
|
||||
例如:
|
||||
|
||||
- `验证并上传`
|
||||
- `codex-*.json`
|
||||
- `sub2api-account-*.json`
|
||||
- `POST /api/upload`
|
||||
|
||||
---
|
||||
|
||||
## 6. 常见问题写法
|
||||
|
||||
常见问题建议用二级或三级标题展开:
|
||||
|
||||
```md
|
||||
## 常见问题
|
||||
|
||||
### 为什么按钮没有出现?
|
||||
|
||||
如果后台没有开启教程显示,前台不会显示“使用教程”按钮。
|
||||
|
||||
### 为什么文件上传失败?
|
||||
|
||||
请先检查文件命名、文件大小和内容格式是否符合要求。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 禁止项
|
||||
|
||||
AI 生成教程时禁止:
|
||||
|
||||
- 使用 HTML
|
||||
- 写内部开发实现细节
|
||||
- 写成公告语气
|
||||
- 写成 PR 说明
|
||||
- 大段堆文字不分章节
|
||||
- 用“应该”“可能”“大概”这种模糊表达描述核心步骤
|
||||
|
||||
不要写:
|
||||
|
||||
- “这里我做了一个功能”
|
||||
- “你应该差不多知道怎么操作”
|
||||
- “这个问题暂时先这样”
|
||||
|
||||
---
|
||||
|
||||
## 8. 推荐语气
|
||||
|
||||
推荐风格:
|
||||
|
||||
- 清楚
|
||||
- 分步骤
|
||||
- 面向第一次使用的人
|
||||
- 说明明确,不省略关键动作
|
||||
|
||||
推荐表达:
|
||||
|
||||
- “先准备”
|
||||
- “然后打开”
|
||||
- “接着点击”
|
||||
- “如果出现以下情况”
|
||||
- “请确认”
|
||||
|
||||
---
|
||||
|
||||
## 9. AI 生成教程时的固定指令
|
||||
|
||||
把下面这段直接发给 AI:
|
||||
|
||||
```md
|
||||
请帮我写一份“账号贡献站 / Codex 注册扩展”的使用教程,严格遵守以下要求:
|
||||
|
||||
1. 输出必须是 Markdown
|
||||
2. 不要使用 HTML
|
||||
3. 面向第一次使用的人来写
|
||||
4. 结构必须清楚,章节明确
|
||||
5. 操作步骤必须按顺序写
|
||||
6. 文件名、接口、按钮名称、路径要用反引号包裹
|
||||
7. 不要写内部开发过程
|
||||
8. 重要部分要短句、短段落,不要写成大段废话
|
||||
|
||||
请按以下结构输出:
|
||||
|
||||
# 标题
|
||||
|
||||
一句话说明
|
||||
|
||||
## 适用场景
|
||||
|
||||
- ...
|
||||
|
||||
## 准备内容
|
||||
|
||||
- ...
|
||||
|
||||
## 操作步骤
|
||||
|
||||
### 第一步:...
|
||||
|
||||
...
|
||||
|
||||
### 第二步:...
|
||||
|
||||
...
|
||||
|
||||
## 常见问题
|
||||
|
||||
### ...
|
||||
|
||||
...
|
||||
|
||||
## 注意事项
|
||||
|
||||
- ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 可直接替换变量模板
|
||||
|
||||
你每次可以这样发给 AI:
|
||||
|
||||
```md
|
||||
请按“账号贡献站使用教程模板”帮我写一份 Markdown 教程。
|
||||
|
||||
本次教程主题:
|
||||
[这里写主题]
|
||||
|
||||
目标读者:
|
||||
[例如:第一次使用贡献页的用户]
|
||||
|
||||
希望教程覆盖的内容:
|
||||
- [内容 1]
|
||||
- [内容 2]
|
||||
- [内容 3]
|
||||
|
||||
必须出现的步骤:
|
||||
1. [步骤 1]
|
||||
2. [步骤 2]
|
||||
3. [步骤 3]
|
||||
|
||||
希望补充的常见问题:
|
||||
- [问题 1]
|
||||
- [问题 2]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 11. 结果验收标准
|
||||
|
||||
生成后的教程 Markdown 必须满足:
|
||||
|
||||
- 复制到管理端后能直接显示
|
||||
- 标题和章节清楚
|
||||
- 教程导航能识别出标题结构
|
||||
- 步骤顺序合理
|
||||
- 没有 HTML
|
||||
- 没有明显 AI 套话
|
||||
- 普通用户能直接照着做
|
||||
+261
@@ -0,0 +1,261 @@
|
||||
# 公告书写标准
|
||||
|
||||
本文件用于约束 AI 生成“账号贡献站 / Codex 注册扩展”相关公告时的 Markdown 格式。
|
||||
|
||||
使用方式:
|
||||
|
||||
- 先把这份文件内容发给 AI
|
||||
- 再补充你这次要发的实际事项
|
||||
- 让 AI 严格按本文格式输出完整 Markdown
|
||||
- 最后把结果直接复制到管理端公告编辑框中
|
||||
|
||||
---
|
||||
|
||||
## 1. 公告用途
|
||||
|
||||
公告用于发布以下信息:
|
||||
|
||||
- 站点新增功能
|
||||
- 上传规则变更
|
||||
- 页面调整
|
||||
- 暂停维护通知
|
||||
- 已知问题说明
|
||||
- 临时提醒
|
||||
- 外部引导信息
|
||||
|
||||
公告不是教程,不需要展开写成长文。
|
||||
|
||||
---
|
||||
|
||||
## 2. 公告总体要求
|
||||
|
||||
AI 生成公告时必须满足:
|
||||
|
||||
1. 使用标准 Markdown
|
||||
2. 不使用 HTML 标签
|
||||
3. 不写成口语化碎碎念
|
||||
4. 标题明确,首段直接说明本次公告主题
|
||||
5. 优先写结论,再写细节
|
||||
6. 语气清晰、正式、简洁
|
||||
7. 尽量让用户在 10 秒内看懂重点
|
||||
|
||||
---
|
||||
|
||||
## 3. 标准结构
|
||||
|
||||
公告建议按以下结构输出:
|
||||
|
||||
```md
|
||||
# 公告标题
|
||||
|
||||
一句话概述本次公告核心内容。
|
||||
|
||||
## 本次调整
|
||||
|
||||
- 要点 1
|
||||
- 要点 2
|
||||
- 要点 3
|
||||
|
||||
## 影响范围
|
||||
|
||||
- 影响项 1
|
||||
- 影响项 2
|
||||
|
||||
## 用户需要做什么
|
||||
|
||||
- 用户动作 1
|
||||
- 用户动作 2
|
||||
|
||||
## 补充说明
|
||||
|
||||
补充说明内容。
|
||||
```
|
||||
|
||||
不是每次都必须写满所有章节,但至少要保证:
|
||||
|
||||
- 有一级标题
|
||||
- 有概述
|
||||
- 有要点列表
|
||||
|
||||
---
|
||||
|
||||
## 4. 标题规范
|
||||
|
||||
标题必须满足:
|
||||
|
||||
- 直接说明主题
|
||||
- 不使用夸张标题党
|
||||
- 不写“通知一下”“说一下”这种模糊表述
|
||||
|
||||
推荐标题示例:
|
||||
|
||||
- `# 账号贡献页新增公告与使用教程入口`
|
||||
- `# 上传规则调整说明`
|
||||
- `# sub2api 文件校验规则更新`
|
||||
- `# 站点临时维护公告`
|
||||
|
||||
不推荐:
|
||||
|
||||
- `# 说个事`
|
||||
- `# 看一下`
|
||||
- `# 更新了`
|
||||
|
||||
---
|
||||
|
||||
## 5. 正文写法规范
|
||||
|
||||
### 5.1 第一段
|
||||
|
||||
第一段必须直接说清:
|
||||
|
||||
- 发生了什么
|
||||
- 对谁有影响
|
||||
- 是否需要操作
|
||||
|
||||
示例:
|
||||
|
||||
```md
|
||||
账号贡献页现已新增公告与使用教程区域,后续站点通知会优先通过公告发布,使用说明会统一整理到教程中。
|
||||
```
|
||||
|
||||
### 5.2 列表
|
||||
|
||||
重要事项优先使用短列表:
|
||||
|
||||
```md
|
||||
## 本次调整
|
||||
|
||||
- 首页右侧新增公告区域
|
||||
- 排行榜与使用教程支持切换查看
|
||||
- 教程内容仅在后台开启且有内容时显示
|
||||
```
|
||||
|
||||
### 5.3 代码或路径
|
||||
|
||||
涉及文件名、路径、接口、命令时必须用反引号包裹:
|
||||
|
||||
- `codex-*.json`
|
||||
- `sub2api-account-*.json`
|
||||
- `POST /api/upload`
|
||||
- `账号贡献/contrib-portal`
|
||||
|
||||
---
|
||||
|
||||
## 6. 禁止项
|
||||
|
||||
AI 生成公告时禁止:
|
||||
|
||||
- 使用 HTML
|
||||
- 写超长大段无分段文字
|
||||
- 同一句里堆太多信息
|
||||
- 写模糊时间词但不写清上下文
|
||||
- 写成教程
|
||||
- 写成 PR 描述
|
||||
- 写内部开发者视角内容
|
||||
|
||||
不要写:
|
||||
|
||||
- “这个地方我改了一下”
|
||||
- “目前来说应该差不多”
|
||||
- “可能有点问题后面再看”
|
||||
|
||||
---
|
||||
|
||||
## 7. 推荐语气
|
||||
|
||||
推荐风格:
|
||||
|
||||
- 冷静
|
||||
- 直接
|
||||
- 不啰嗦
|
||||
- 对用户友好
|
||||
|
||||
推荐表达:
|
||||
|
||||
- “现已支持”
|
||||
- “已调整为”
|
||||
- “后续将通过此处发布”
|
||||
- “如你正在使用旧流程,请注意以下变化”
|
||||
|
||||
---
|
||||
|
||||
## 8. AI 生成公告时的固定指令
|
||||
|
||||
把下面这段直接发给 AI:
|
||||
|
||||
```md
|
||||
请帮我写一份“账号贡献站 / Codex 注册扩展”公告,严格遵守以下要求:
|
||||
|
||||
1. 输出必须是 Markdown
|
||||
2. 不要使用 HTML
|
||||
3. 风格简洁、正式、清楚
|
||||
4. 先写结论,再写细节
|
||||
5. 重要信息尽量用短列表
|
||||
6. 涉及文件名、路径、接口、命令时用反引号包裹
|
||||
7. 不要写成长教程
|
||||
8. 不要写内部开发过程,只写用户看得懂的公告
|
||||
|
||||
请按以下结构输出:
|
||||
|
||||
# 标题
|
||||
|
||||
一句话概述
|
||||
|
||||
## 本次调整
|
||||
|
||||
- ...
|
||||
|
||||
## 影响范围
|
||||
|
||||
- ...
|
||||
|
||||
## 用户需要做什么
|
||||
|
||||
- ...
|
||||
|
||||
## 补充说明
|
||||
|
||||
...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. 可直接替换变量模板
|
||||
|
||||
你每次可以这样发给 AI:
|
||||
|
||||
```md
|
||||
请按“账号贡献站公告标准”帮我写一份 Markdown 公告。
|
||||
|
||||
本次公告主题:
|
||||
[这里写主题]
|
||||
|
||||
这次变更点:
|
||||
- [变更点 1]
|
||||
- [变更点 2]
|
||||
- [变更点 3]
|
||||
|
||||
希望重点强调:
|
||||
- [强调点 1]
|
||||
- [强调点 2]
|
||||
|
||||
用户是否需要额外操作:
|
||||
[是 / 否]
|
||||
|
||||
如果需要,操作内容:
|
||||
- [操作 1]
|
||||
- [操作 2]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 结果验收标准
|
||||
|
||||
生成后的 Markdown 必须满足:
|
||||
|
||||
- 复制到管理端后能直接显示
|
||||
- 结构清楚
|
||||
- 第一眼能看懂重点
|
||||
- 没有 HTML
|
||||
- 没有明显 AI 套话
|
||||
- 没有多余开发术语
|
||||
@@ -0,0 +1,162 @@
|
||||
(function mail2925UtilsModule(root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = factory();
|
||||
return;
|
||||
}
|
||||
|
||||
root.Mail2925Utils = factory();
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createMail2925Utils() {
|
||||
const MAIL2925_LIMIT_COOLDOWN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function normalizeTimestamp(value) {
|
||||
const numeric = Number(value);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : 0;
|
||||
}
|
||||
|
||||
function normalizeMail2925Account(account = {}) {
|
||||
return {
|
||||
id: String(account.id || crypto.randomUUID()),
|
||||
email: String(account.email || '').trim().toLowerCase(),
|
||||
password: String(account.password || ''),
|
||||
enabled: account.enabled !== undefined ? Boolean(account.enabled) : true,
|
||||
lastUsedAt: normalizeTimestamp(account.lastUsedAt),
|
||||
lastLoginAt: normalizeTimestamp(account.lastLoginAt),
|
||||
lastLimitAt: normalizeTimestamp(account.lastLimitAt),
|
||||
disabledUntil: normalizeTimestamp(account.disabledUntil),
|
||||
lastError: String(account.lastError || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMail2925Accounts(accounts) {
|
||||
if (!Array.isArray(accounts)) return [];
|
||||
|
||||
const deduped = new Map();
|
||||
for (const account of accounts) {
|
||||
const normalized = normalizeMail2925Account(account);
|
||||
if (!normalized.email) continue;
|
||||
deduped.set(normalized.id, normalized);
|
||||
}
|
||||
return [...deduped.values()];
|
||||
}
|
||||
|
||||
function findMail2925Account(accounts, accountId) {
|
||||
return normalizeMail2925Accounts(accounts).find((account) => account.id === accountId) || null;
|
||||
}
|
||||
|
||||
function isMail2925AccountCoolingDown(account, now = Date.now()) {
|
||||
return normalizeTimestamp(account?.disabledUntil) > normalizeTimestamp(now);
|
||||
}
|
||||
|
||||
function isMail2925AccountAvailable(account, now = Date.now()) {
|
||||
return Boolean(account)
|
||||
&& Boolean(account.email)
|
||||
&& Boolean(account.password)
|
||||
&& account.enabled !== false
|
||||
&& !isMail2925AccountCoolingDown(account, now);
|
||||
}
|
||||
|
||||
function getMail2925AccountStatus(account, now = Date.now()) {
|
||||
if (!account) return 'missing';
|
||||
if (account.enabled === false) return 'disabled';
|
||||
if (isMail2925AccountCoolingDown(account, now)) return 'cooldown';
|
||||
if (!account.password) return 'pending';
|
||||
if (account.lastError) return 'error';
|
||||
return 'ready';
|
||||
}
|
||||
|
||||
function filterMail2925AccountsByStatus(accounts, mode = 'all', now = Date.now()) {
|
||||
const list = normalizeMail2925Accounts(accounts);
|
||||
switch (String(mode || 'all').trim()) {
|
||||
case 'available':
|
||||
return list.filter((account) => isMail2925AccountAvailable(account, now));
|
||||
case 'cooldown':
|
||||
return list.filter((account) => isMail2925AccountCoolingDown(account, now));
|
||||
case 'disabled':
|
||||
return list.filter((account) => account.enabled === false);
|
||||
default:
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
function pickMail2925AccountForRun(accounts, options = {}) {
|
||||
const now = normalizeTimestamp(options.now) || Date.now();
|
||||
const excludeIds = new Set((options.excludeIds || []).filter(Boolean));
|
||||
const candidates = normalizeMail2925Accounts(accounts)
|
||||
.filter((account) => isMail2925AccountAvailable(account, now));
|
||||
if (!candidates.length) return null;
|
||||
|
||||
const filtered = candidates.filter((account) => !excludeIds.has(account.id));
|
||||
const pool = filtered.length ? filtered : candidates;
|
||||
|
||||
return pool
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftUsedAt = normalizeTimestamp(left.lastUsedAt);
|
||||
const rightUsedAt = normalizeTimestamp(right.lastUsedAt);
|
||||
if (leftUsedAt !== rightUsedAt) {
|
||||
return leftUsedAt - rightUsedAt;
|
||||
}
|
||||
return String(left.email || '').localeCompare(String(right.email || ''));
|
||||
})[0] || null;
|
||||
}
|
||||
|
||||
function getMail2925BulkActionLabel(mode = 'all', count = 0) {
|
||||
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
|
||||
const prefix = mode === 'cooldown' ? '清空冷却' : '全部删除';
|
||||
const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
|
||||
return `${prefix}${suffix}`;
|
||||
}
|
||||
|
||||
function getMail2925ListToggleLabel(expanded, count = 0) {
|
||||
const normalizedCount = Number.isFinite(Number(count)) ? Math.max(0, Number(count)) : 0;
|
||||
const suffix = normalizedCount > 0 ? `(${normalizedCount})` : '';
|
||||
return `${expanded ? '收起列表' : '展开列表'}${suffix}`;
|
||||
}
|
||||
|
||||
function upsertMail2925AccountInList(accounts, nextAccount) {
|
||||
const list = Array.isArray(accounts) ? accounts.slice() : [];
|
||||
if (!nextAccount?.id) return list;
|
||||
|
||||
const existingIndex = list.findIndex((account) => account?.id === nextAccount.id);
|
||||
if (existingIndex === -1) {
|
||||
list.push(nextAccount);
|
||||
return list;
|
||||
}
|
||||
|
||||
list[existingIndex] = nextAccount;
|
||||
return list;
|
||||
}
|
||||
|
||||
function parseMail2925ImportText(rawText) {
|
||||
const lines = String(rawText || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return lines
|
||||
.filter((line, index) => !(index === 0 && /^邮箱----密码$/i.test(line)))
|
||||
.map((line) => line.split('----').map((part) => part.trim()))
|
||||
.filter((parts) => parts.length >= 2 && parts[0] && parts[1])
|
||||
.map(([email, password]) => ({
|
||||
email,
|
||||
password,
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
filterMail2925AccountsByStatus,
|
||||
findMail2925Account,
|
||||
getMail2925AccountStatus,
|
||||
getMail2925BulkActionLabel,
|
||||
getMail2925ListToggleLabel,
|
||||
isMail2925AccountAvailable,
|
||||
isMail2925AccountCoolingDown,
|
||||
normalizeMail2925Account,
|
||||
normalizeMail2925Accounts,
|
||||
normalizeTimestamp,
|
||||
parseMail2925ImportText,
|
||||
pickMail2925AccountForRun,
|
||||
upsertMail2925AccountInList,
|
||||
};
|
||||
});
|
||||
@@ -3,6 +3,9 @@
|
||||
})(typeof self !== 'undefined' ? self : globalThis, function createManagedAliasUtilsModule() {
|
||||
const GMAIL_PROVIDER = 'gmail';
|
||||
const MAIL_2925_PROVIDER = '2925';
|
||||
const MAIL_2925_MODE_PROVIDE = 'provide';
|
||||
const MAIL_2925_MODE_RECEIVE = 'receive';
|
||||
const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE;
|
||||
|
||||
const PROVIDER_CONFIGS = {
|
||||
[GMAIL_PROVIDER]: {
|
||||
@@ -64,10 +67,31 @@
|
||||
return PROVIDER_CONFIGS[String(provider || '').trim().toLowerCase()] || null;
|
||||
}
|
||||
|
||||
function normalizeMail2925Mode(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === MAIL_2925_MODE_RECEIVE
|
||||
? MAIL_2925_MODE_RECEIVE
|
||||
: DEFAULT_MAIL_2925_MODE;
|
||||
}
|
||||
|
||||
function isManagedAliasProvider(provider = '') {
|
||||
return Boolean(getManagedAliasProviderConfig(provider));
|
||||
}
|
||||
|
||||
function usesManagedAliasGeneration(provider = '', options = {}) {
|
||||
const normalizedProvider = String(provider || '').trim().toLowerCase();
|
||||
if (!isManagedAliasProvider(normalizedProvider)) {
|
||||
return false;
|
||||
}
|
||||
if (normalizedProvider !== MAIL_2925_PROVIDER) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const mail2925Mode = typeof options === 'string'
|
||||
? options
|
||||
: options?.mail2925Mode;
|
||||
return normalizeMail2925Mode(mail2925Mode) === MAIL_2925_MODE_PROVIDE;
|
||||
}
|
||||
|
||||
function parseEmailParts(rawValue = '') {
|
||||
const value = String(rawValue || '').trim().toLowerCase();
|
||||
const match = value.match(/^([^@\s]+)@([^@\s]+\.[^@\s]+)$/);
|
||||
@@ -136,11 +160,16 @@
|
||||
|
||||
return {
|
||||
buildManagedAliasEmail,
|
||||
DEFAULT_MAIL_2925_MODE,
|
||||
getManagedAliasProviderConfig,
|
||||
getManagedAliasProviderUiCopy,
|
||||
isManagedAliasEmail,
|
||||
isManagedAliasProvider,
|
||||
MAIL_2925_MODE_PROVIDE,
|
||||
MAIL_2925_MODE_RECEIVE,
|
||||
normalizeMail2925Mode,
|
||||
parseEmailParts,
|
||||
parseManagedAliasBaseEmail,
|
||||
usesManagedAliasGeneration,
|
||||
};
|
||||
});
|
||||
|
||||
+3
-3
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "多页面自动化",
|
||||
"version": "3.3",
|
||||
"version_name": "Pro3.3",
|
||||
"name": "codex-oauth-automation-extension",
|
||||
"version": "7.0",
|
||||
"version_name": "Pro7.0",
|
||||
"description": "用于自动执行多步骤 OAuth 注册流程",
|
||||
"permissions": [
|
||||
"sidePanel",
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
(function attachSidepanelAccountPoolUi(globalScope) {
|
||||
function createAccountPoolFormController(options = {}) {
|
||||
const {
|
||||
formShell = null,
|
||||
toggleButton = null,
|
||||
hiddenLabel = '添加账号',
|
||||
visibleLabel = '取消添加',
|
||||
onClear = null,
|
||||
onFocus = null,
|
||||
} = options;
|
||||
|
||||
let visible = false;
|
||||
|
||||
function sync() {
|
||||
if (formShell) {
|
||||
formShell.hidden = !visible;
|
||||
}
|
||||
if (toggleButton) {
|
||||
toggleButton.textContent = visible ? visibleLabel : hiddenLabel;
|
||||
toggleButton.setAttribute('aria-expanded', String(visible));
|
||||
}
|
||||
}
|
||||
|
||||
function setVisible(nextVisible, controlOptions = {}) {
|
||||
const {
|
||||
clearForm = false,
|
||||
focusField = false,
|
||||
} = controlOptions;
|
||||
|
||||
visible = Boolean(nextVisible);
|
||||
if (clearForm && typeof onClear === 'function') {
|
||||
onClear();
|
||||
}
|
||||
|
||||
sync();
|
||||
|
||||
if (visible && focusField && typeof onFocus === 'function') {
|
||||
onFocus();
|
||||
}
|
||||
}
|
||||
|
||||
function isVisible() {
|
||||
return visible;
|
||||
}
|
||||
|
||||
sync();
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
setVisible,
|
||||
sync,
|
||||
};
|
||||
}
|
||||
|
||||
globalScope.SidepanelAccountPoolUi = {
|
||||
createAccountPoolFormController,
|
||||
};
|
||||
})(window);
|
||||
@@ -0,0 +1,147 @@
|
||||
(() => {
|
||||
const PORTAL_BASE_URL = 'https://apikey.qzz.io';
|
||||
const CONTENT_SUMMARY_API_URL = `${PORTAL_BASE_URL}/api/content-summary`;
|
||||
const CACHE_KEY = 'multipage-contribution-content-summary-v1';
|
||||
const FETCH_TIMEOUT_MS = 6000;
|
||||
|
||||
function sanitizeItem(item = {}) {
|
||||
return {
|
||||
slug: String(item?.slug || '').trim(),
|
||||
title: String(item?.title || '').trim(),
|
||||
isEnabled: Boolean(item?.is_enabled),
|
||||
hasContent: Boolean(item?.has_content),
|
||||
isVisible: Boolean(item?.is_visible),
|
||||
updatedAt: String(item?.updated_at || '').trim(),
|
||||
updatedAtDisplay: String(item?.updated_at_display || '').trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildSnapshot(payload = {}) {
|
||||
const items = Array.isArray(payload?.items)
|
||||
? payload.items.map(sanitizeItem).filter((item) => item.slug)
|
||||
: [];
|
||||
const promptVersion = String(payload?.prompt_version || '').trim();
|
||||
const latestUpdatedAt = String(payload?.latest_updated_at || '').trim();
|
||||
const latestUpdatedAtDisplay = String(payload?.latest_updated_at_display || '').trim();
|
||||
const hasVisibleUpdates = Boolean(payload?.has_visible_updates) && Boolean(promptVersion);
|
||||
|
||||
return {
|
||||
status: hasVisibleUpdates ? 'update-available' : 'idle',
|
||||
promptVersion,
|
||||
hasVisibleUpdates,
|
||||
latestUpdatedAt,
|
||||
latestUpdatedAtDisplay,
|
||||
items,
|
||||
portalUrl: PORTAL_BASE_URL,
|
||||
apiUrl: CONTENT_SUMMARY_API_URL,
|
||||
checkedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function readCache() {
|
||||
try {
|
||||
const raw = localStorage.getItem(CACHE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const snapshot = buildSnapshot({
|
||||
items: parsed.items,
|
||||
prompt_version: parsed.promptVersion,
|
||||
has_visible_updates: parsed.hasVisibleUpdates,
|
||||
latest_updated_at: parsed.latestUpdatedAt,
|
||||
latest_updated_at_display: parsed.latestUpdatedAtDisplay,
|
||||
});
|
||||
if (!Number.isFinite(parsed.checkedAt)) {
|
||||
return snapshot;
|
||||
}
|
||||
snapshot.checkedAt = parsed.checkedAt;
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(snapshot) {
|
||||
try {
|
||||
localStorage.setItem(CACHE_KEY, JSON.stringify(snapshot));
|
||||
} catch (error) {
|
||||
// Ignore cache write failures.
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchContentSummary() {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
|
||||
try {
|
||||
const response = await fetch(CONTENT_SUMMARY_API_URL, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`内容摘要请求失败:${response.status}`);
|
||||
}
|
||||
|
||||
const payload = await response.json();
|
||||
if (!payload || payload.ok !== true) {
|
||||
throw new Error('内容摘要返回格式异常');
|
||||
}
|
||||
|
||||
const snapshot = buildSnapshot(payload);
|
||||
writeCache(snapshot);
|
||||
return snapshot;
|
||||
} catch (error) {
|
||||
if (error?.name === 'AbortError') {
|
||||
throw new Error('内容摘要请求超时');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
async function getContentUpdateSnapshot() {
|
||||
try {
|
||||
return await fetchContentSummary();
|
||||
} catch (error) {
|
||||
const cached = readCache();
|
||||
if (cached) {
|
||||
return {
|
||||
...cached,
|
||||
fromCache: true,
|
||||
errorMessage: error?.message || '内容摘要获取失败',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'error',
|
||||
promptVersion: '',
|
||||
hasVisibleUpdates: false,
|
||||
latestUpdatedAt: '',
|
||||
latestUpdatedAtDisplay: '',
|
||||
items: [],
|
||||
portalUrl: PORTAL_BASE_URL,
|
||||
apiUrl: CONTENT_SUMMARY_API_URL,
|
||||
checkedAt: Date.now(),
|
||||
errorMessage: error?.message || '内容摘要获取失败',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
window.SidepanelContributionContentService = {
|
||||
getContentUpdateSnapshot,
|
||||
portalUrl: PORTAL_BASE_URL,
|
||||
apiUrl: CONTENT_SUMMARY_API_URL,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,471 @@
|
||||
(function attachSidepanelContributionMode(globalScope) {
|
||||
const ACTIVE_STATUSES = new Set(['started', 'waiting', 'processing']);
|
||||
const FINAL_STATUSES = new Set(['auto_approved', 'auto_rejected', 'manual_review_required', 'expired', 'error']);
|
||||
const DEFAULT_COPY = '当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,并继续等待 CPA 最终确认。';
|
||||
|
||||
function createContributionModeManager(context = {}) {
|
||||
const {
|
||||
state,
|
||||
dom,
|
||||
helpers,
|
||||
runtime,
|
||||
constants = {},
|
||||
} = context;
|
||||
|
||||
const contributionUploadUrl = constants.contributionUploadUrl || 'https://apikey.qzz.io';
|
||||
const pollIntervalMs = Math.max(1500, Math.floor(Number(constants.pollIntervalMs) || 2500));
|
||||
|
||||
const hiddenRows = [
|
||||
dom.rowVpsUrl,
|
||||
dom.rowVpsPassword,
|
||||
dom.rowLocalCpaStep9Mode,
|
||||
dom.rowSub2ApiUrl,
|
||||
dom.rowSub2ApiEmail,
|
||||
dom.rowSub2ApiPassword,
|
||||
dom.rowSub2ApiGroup,
|
||||
dom.rowSub2ApiDefaultProxy,
|
||||
dom.rowCodex2ApiUrl,
|
||||
dom.rowCodex2ApiAdminKey,
|
||||
dom.rowCustomPassword,
|
||||
dom.rowAccountRunHistoryHelperBaseUrl,
|
||||
].filter(Boolean);
|
||||
|
||||
let actionInFlight = false;
|
||||
let pollInFlight = false;
|
||||
let pollTimer = null;
|
||||
|
||||
function getLatestState() {
|
||||
return state.getLatestState?.() || {};
|
||||
}
|
||||
|
||||
function normalizeString(value = '') {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
if (ACTIVE_STATUSES.has(normalized) || FINAL_STATUSES.has(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function normalizeCallbackStatus(value = '') {
|
||||
const normalized = normalizeString(value).toLowerCase();
|
||||
switch (normalized) {
|
||||
case 'waiting':
|
||||
case 'captured':
|
||||
case 'submitting':
|
||||
case 'submitted':
|
||||
case 'failed':
|
||||
case 'idle':
|
||||
return normalized;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function isContributionModeEnabled(currentState = getLatestState()) {
|
||||
return Boolean(currentState.contributionMode);
|
||||
}
|
||||
|
||||
function hasActiveContributionSession(currentState = getLatestState()) {
|
||||
const status = normalizeStatus(currentState.contributionStatus);
|
||||
return Boolean(normalizeString(currentState.contributionSessionId) && status && !FINAL_STATUSES.has(status));
|
||||
}
|
||||
|
||||
function isModeSwitchBlocked() {
|
||||
return Boolean(helpers.isModeSwitchBlocked?.(getLatestState()));
|
||||
}
|
||||
|
||||
function setContributionHidden(element, hidden) {
|
||||
element?.classList.toggle('is-contribution-hidden', hidden);
|
||||
}
|
||||
|
||||
function syncContributionRows(enabled) {
|
||||
hiddenRows.forEach((row) => {
|
||||
setContributionHidden(row, enabled);
|
||||
});
|
||||
}
|
||||
|
||||
function syncContributionButton(enabled, blocked) {
|
||||
if (!dom.btnContributionMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
dom.btnContributionMode.classList.toggle('is-active', enabled);
|
||||
dom.btnContributionMode.setAttribute('aria-pressed', String(enabled));
|
||||
dom.btnContributionMode.disabled = enabled || blocked;
|
||||
dom.btnContributionMode.title = blocked
|
||||
? '当前流程运行中,暂时不能切换贡献模式'
|
||||
: (enabled ? '当前已在贡献模式' : '进入贡献模式');
|
||||
}
|
||||
|
||||
function stopPolling() {
|
||||
if (pollTimer) {
|
||||
clearTimeout(pollTimer);
|
||||
pollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePolling(delayMs = pollIntervalMs) {
|
||||
stopPolling();
|
||||
if (!isContributionModeEnabled() || !hasActiveContributionSession()) {
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimer = setTimeout(() => {
|
||||
pollOnce({ silentError: true }).catch(() => {});
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function ensurePolling() {
|
||||
if (!isContributionModeEnabled() || !hasActiveContributionSession()) {
|
||||
stopPolling();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!pollTimer && !pollInFlight) {
|
||||
schedulePolling(1200);
|
||||
}
|
||||
}
|
||||
|
||||
function getOauthStatusText(currentState = getLatestState()) {
|
||||
const status = normalizeStatus(currentState.contributionStatus);
|
||||
const hasAuthUrl = Boolean(normalizeString(currentState.contributionAuthUrl));
|
||||
if (!normalizeString(currentState.contributionSessionId) || !hasAuthUrl) {
|
||||
return '未生成登录地址';
|
||||
}
|
||||
if (status === 'waiting') {
|
||||
return '等待提交回调';
|
||||
}
|
||||
if (status === 'processing' || status === 'auto_approved' || status === 'auto_rejected' || status === 'manual_review_required') {
|
||||
return status === 'processing' ? '已提交回调' : '授权已结束';
|
||||
}
|
||||
if (status === 'expired' || status === 'error') {
|
||||
return '授权失败';
|
||||
}
|
||||
if (Number(currentState.contributionAuthOpenedAt) > 0) {
|
||||
return '已打开授权页';
|
||||
}
|
||||
return '登录地址已生成';
|
||||
}
|
||||
|
||||
function getCallbackStatusText(currentState = getLatestState()) {
|
||||
const status = normalizeCallbackStatus(currentState.contributionCallbackStatus);
|
||||
switch (status) {
|
||||
case 'captured':
|
||||
return '已捕获回调地址';
|
||||
case 'submitting':
|
||||
return '正在提交回调';
|
||||
case 'submitted':
|
||||
return '已提交回调';
|
||||
case 'failed':
|
||||
return '回调提交失败';
|
||||
case 'waiting':
|
||||
case 'idle':
|
||||
default:
|
||||
return normalizeString(currentState.contributionCallbackUrl)
|
||||
? '已捕获回调地址'
|
||||
: '等待回调';
|
||||
}
|
||||
}
|
||||
|
||||
function getSummaryText(currentState = getLatestState()) {
|
||||
return normalizeString(currentState.contributionStatusMessage) || DEFAULT_COPY;
|
||||
}
|
||||
|
||||
function getContributionUploadPageUrl() {
|
||||
return normalizeString(contributionUploadUrl);
|
||||
}
|
||||
|
||||
function openContributionUploadPage() {
|
||||
const targetUrl = getContributionUploadPageUrl();
|
||||
if (!targetUrl) {
|
||||
return;
|
||||
}
|
||||
helpers.openExternalUrl?.(targetUrl);
|
||||
}
|
||||
|
||||
async function syncContributionProfile(partial = {}) {
|
||||
const payload = {
|
||||
nickname: normalizeString(partial.nickname),
|
||||
qq: normalizeString(partial.qq),
|
||||
};
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'SET_CONTRIBUTION_PROFILE',
|
||||
source: 'sidepanel',
|
||||
payload,
|
||||
});
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
if (response?.state) {
|
||||
helpers.applySettingsState?.(response.state);
|
||||
}
|
||||
}
|
||||
|
||||
async function requestContributionMode(enabled) {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'SET_CONTRIBUTION_MODE',
|
||||
source: 'sidepanel',
|
||||
payload: { enabled: Boolean(enabled) },
|
||||
});
|
||||
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
if (!response?.state) {
|
||||
throw new Error('贡献模式切换后未返回最新状态。');
|
||||
}
|
||||
|
||||
helpers.applySettingsState?.(response.state);
|
||||
helpers.updateStatusDisplay?.(response.state);
|
||||
render();
|
||||
}
|
||||
|
||||
async function pollOnce(options = {}) {
|
||||
if (pollInFlight || !isContributionModeEnabled() || !hasActiveContributionSession()) {
|
||||
if (!hasActiveContributionSession()) {
|
||||
stopPolling();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
pollInFlight = true;
|
||||
try {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'POLL_CONTRIBUTION_STATUS',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
reason: options.reason || 'sidepanel_poll',
|
||||
},
|
||||
});
|
||||
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
if (response?.state) {
|
||||
helpers.applySettingsState?.(response.state);
|
||||
helpers.updateStatusDisplay?.(response.state);
|
||||
}
|
||||
} finally {
|
||||
pollInFlight = false;
|
||||
render();
|
||||
if (hasActiveContributionSession()) {
|
||||
schedulePolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function startContributionFlow() {
|
||||
if (typeof helpers.startContributionAutoRun !== 'function') {
|
||||
throw new Error('贡献模式尚未接入主自动流程启动能力。');
|
||||
}
|
||||
|
||||
const profile = helpers.getContributionProfile?.() || {};
|
||||
const qq = normalizeString(profile.qq);
|
||||
if (qq && !/^\d{1,20}$/.test(qq)) {
|
||||
throw new Error('QQ 只能填写数字,且长度不能超过 20 位。');
|
||||
}
|
||||
await syncContributionProfile(profile);
|
||||
|
||||
const started = await helpers.startContributionAutoRun();
|
||||
if (!started) {
|
||||
return;
|
||||
}
|
||||
|
||||
helpers.showToast?.('贡献自动流程已启动。', 'info', 1800);
|
||||
render();
|
||||
}
|
||||
|
||||
async function enterContributionMode() {
|
||||
await requestContributionMode(true);
|
||||
helpers.showToast?.('已进入贡献模式。', 'success', 1800);
|
||||
}
|
||||
|
||||
async function exitContributionMode() {
|
||||
stopPolling();
|
||||
await requestContributionMode(false);
|
||||
helpers.showToast?.('已退出贡献模式。', 'info', 1800);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const currentState = getLatestState();
|
||||
const enabled = isContributionModeEnabled(currentState);
|
||||
const blocked = isModeSwitchBlocked();
|
||||
const activeElement = typeof document !== 'undefined' ? document.activeElement : null;
|
||||
|
||||
if (enabled && dom.selectPanelMode) {
|
||||
dom.selectPanelMode.value = 'cpa';
|
||||
}
|
||||
|
||||
helpers.updatePanelModeUI?.();
|
||||
helpers.updateAccountRunHistorySettingsUI?.();
|
||||
|
||||
if (dom.contributionModePanel) {
|
||||
dom.contributionModePanel.hidden = !enabled;
|
||||
}
|
||||
if (dom.contributionModeText) {
|
||||
dom.contributionModeText.textContent = DEFAULT_COPY;
|
||||
}
|
||||
if (dom.inputContributionNickname && activeElement !== dom.inputContributionNickname) {
|
||||
const nextNickname = normalizeString(currentState.contributionNickname);
|
||||
if (nextNickname || !normalizeString(dom.inputContributionNickname.value)) {
|
||||
dom.inputContributionNickname.value = nextNickname;
|
||||
}
|
||||
}
|
||||
if (dom.inputContributionQq && activeElement !== dom.inputContributionQq) {
|
||||
const nextQq = normalizeString(currentState.contributionQq);
|
||||
if (nextQq || !normalizeString(dom.inputContributionQq.value)) {
|
||||
dom.inputContributionQq.value = nextQq;
|
||||
}
|
||||
}
|
||||
if (dom.contributionOauthStatus) {
|
||||
dom.contributionOauthStatus.textContent = getOauthStatusText(currentState);
|
||||
}
|
||||
if (dom.contributionCallbackStatus) {
|
||||
dom.contributionCallbackStatus.textContent = getCallbackStatusText(currentState);
|
||||
}
|
||||
if (dom.contributionModeSummary) {
|
||||
dom.contributionModeSummary.textContent = getSummaryText(currentState);
|
||||
}
|
||||
|
||||
syncContributionRows(enabled);
|
||||
syncContributionButton(enabled, blocked);
|
||||
|
||||
if (dom.selectPanelMode) {
|
||||
dom.selectPanelMode.disabled = enabled;
|
||||
}
|
||||
|
||||
if (dom.btnStartContribution) {
|
||||
dom.btnStartContribution.disabled = actionInFlight || blocked;
|
||||
}
|
||||
|
||||
if (dom.btnOpenContributionUpload) {
|
||||
dom.btnOpenContributionUpload.disabled = false;
|
||||
}
|
||||
|
||||
if (dom.btnExitContributionMode) {
|
||||
dom.btnExitContributionMode.disabled = actionInFlight || blocked;
|
||||
dom.btnExitContributionMode.title = blocked ? '当前流程运行中,暂时不能退出贡献模式' : '退出贡献模式';
|
||||
}
|
||||
|
||||
if (dom.btnOpenAccountRecords) {
|
||||
dom.btnOpenAccountRecords.disabled = enabled;
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
helpers.closeConfigMenu?.();
|
||||
helpers.closeAccountRecordsPanel?.();
|
||||
ensurePolling();
|
||||
} else {
|
||||
stopPolling();
|
||||
}
|
||||
|
||||
helpers.updateConfigMenuControls?.();
|
||||
}
|
||||
|
||||
function bindEvents() {
|
||||
dom.btnContributionMode?.addEventListener('click', async () => {
|
||||
if (actionInFlight) {
|
||||
return;
|
||||
}
|
||||
actionInFlight = true;
|
||||
try {
|
||||
openContributionUploadPage();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(`打开上传页面失败:${error.message}`, 'error');
|
||||
}
|
||||
render();
|
||||
try {
|
||||
await enterContributionMode();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
dom.btnStartContribution?.addEventListener('click', async () => {
|
||||
if (actionInFlight) {
|
||||
return;
|
||||
}
|
||||
actionInFlight = true;
|
||||
render();
|
||||
try {
|
||||
await startContributionFlow();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
dom.inputContributionNickname?.addEventListener('change', async () => {
|
||||
try {
|
||||
await syncContributionProfile({
|
||||
nickname: dom.inputContributionNickname?.value,
|
||||
qq: dom.inputContributionQq?.value,
|
||||
});
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
dom.inputContributionQq?.addEventListener('change', async () => {
|
||||
try {
|
||||
await syncContributionProfile({
|
||||
nickname: dom.inputContributionNickname?.value,
|
||||
qq: dom.inputContributionQq?.value,
|
||||
});
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
render();
|
||||
}
|
||||
});
|
||||
|
||||
dom.btnOpenContributionUpload?.addEventListener('click', () => {
|
||||
try {
|
||||
openContributionUploadPage();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(`打开上传页面失败:${error.message}`, 'error');
|
||||
}
|
||||
});
|
||||
|
||||
dom.btnExitContributionMode?.addEventListener('click', async () => {
|
||||
if (actionInFlight) {
|
||||
return;
|
||||
}
|
||||
actionInFlight = true;
|
||||
render();
|
||||
try {
|
||||
await exitContributionMode();
|
||||
} catch (error) {
|
||||
helpers.showToast?.(error.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
bindEvents,
|
||||
pollOnce,
|
||||
render,
|
||||
stopPolling,
|
||||
};
|
||||
}
|
||||
|
||||
globalScope.SidepanelContributionMode = {
|
||||
createContributionModeManager,
|
||||
};
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -12,6 +12,7 @@
|
||||
const expandedStorageKey = constants.expandedStorageKey || 'multipage-hotmail-list-expanded';
|
||||
const displayTimeZone = constants.displayTimeZone || 'Asia/Shanghai';
|
||||
const copyIcon = constants.copyIcon || '';
|
||||
const createAccountPoolFormController = globalScope.SidepanelAccountPoolUi?.createAccountPoolFormController;
|
||||
|
||||
let actionInFlight = false;
|
||||
let listExpanded = false;
|
||||
@@ -177,6 +178,25 @@
|
||||
dom.inputHotmailRefreshToken.value = '';
|
||||
}
|
||||
|
||||
const formController = typeof createAccountPoolFormController === 'function'
|
||||
? createAccountPoolFormController({
|
||||
formShell: dom.hotmailFormShell,
|
||||
toggleButton: dom.btnToggleHotmailForm,
|
||||
hiddenLabel: '添加账号',
|
||||
visibleLabel: '取消添加',
|
||||
onClear: () => {
|
||||
clearHotmailForm();
|
||||
},
|
||||
onFocus: () => {
|
||||
dom.inputHotmailEmail?.focus?.();
|
||||
},
|
||||
})
|
||||
: {
|
||||
isVisible: () => false,
|
||||
setVisible() {},
|
||||
sync() {},
|
||||
};
|
||||
|
||||
function renderHotmailAccounts() {
|
||||
if (!dom.hotmailAccountsList) return;
|
||||
const latestState = state.getLatestState();
|
||||
@@ -318,7 +338,7 @@
|
||||
}
|
||||
|
||||
helpers.showToast(`已保存 Hotmail 账号 ${email}`, 'success', 1800);
|
||||
clearHotmailForm();
|
||||
formController.setVisible(false, { clearForm: true });
|
||||
} catch (err) {
|
||||
helpers.showToast(`保存 Hotmail 账号失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
@@ -474,6 +494,14 @@
|
||||
setHotmailListExpanded(!listExpanded);
|
||||
});
|
||||
|
||||
dom.btnToggleHotmailForm?.addEventListener('click', () => {
|
||||
if (formController.isVisible()) {
|
||||
formController.setVisible(false, { clearForm: true });
|
||||
return;
|
||||
}
|
||||
formController.setVisible(true, { focusField: true });
|
||||
});
|
||||
|
||||
dom.btnHotmailUsageGuide?.addEventListener('click', async () => {
|
||||
await helpers.openConfirmModal({
|
||||
title: '使用教程',
|
||||
@@ -514,6 +542,7 @@
|
||||
dom.btnAddHotmailAccount?.addEventListener('click', handleAddHotmailAccount);
|
||||
dom.btnImportHotmailAccounts?.addEventListener('click', handleImportHotmailAccounts);
|
||||
dom.hotmailAccountsList?.addEventListener('click', handleAccountListClick);
|
||||
formController.sync();
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
(function attachSidepanelMail2925Manager(globalScope) {
|
||||
function createMail2925Manager(context = {}) {
|
||||
const {
|
||||
state,
|
||||
dom,
|
||||
helpers,
|
||||
runtime,
|
||||
constants = {},
|
||||
mail2925Utils = {},
|
||||
} = context;
|
||||
|
||||
const expandedStorageKey = constants.expandedStorageKey || 'multipage-mail2925-list-expanded';
|
||||
const displayTimeZone = constants.displayTimeZone || 'Asia/Shanghai';
|
||||
const copyIcon = constants.copyIcon || '';
|
||||
const createAccountPoolFormController = globalScope.SidepanelAccountPoolUi?.createAccountPoolFormController;
|
||||
|
||||
let actionInFlight = false;
|
||||
let listExpanded = false;
|
||||
let editingAccountId = '';
|
||||
|
||||
function getMail2925Accounts(currentState = state.getLatestState()) {
|
||||
return helpers.getMail2925Accounts(currentState);
|
||||
}
|
||||
|
||||
function getCurrentMail2925AccountId(currentState = state.getLatestState()) {
|
||||
return String(currentState?.currentMail2925AccountId || '');
|
||||
}
|
||||
|
||||
function updateMail2925ListViewport() {
|
||||
const count = getMail2925Accounts().length;
|
||||
if (dom.btnDeleteAllMail2925Accounts) {
|
||||
dom.btnDeleteAllMail2925Accounts.textContent = `全部删除${count > 0 ? `(${count})` : ''}`;
|
||||
dom.btnDeleteAllMail2925Accounts.disabled = count === 0;
|
||||
}
|
||||
if (dom.btnToggleMail2925List) {
|
||||
const label = typeof mail2925Utils.getMail2925ListToggleLabel === 'function'
|
||||
? mail2925Utils.getMail2925ListToggleLabel(listExpanded, count)
|
||||
: `${listExpanded ? '收起列表' : '展开列表'}${count > 0 ? `(${count})` : ''}`;
|
||||
dom.btnToggleMail2925List.textContent = label;
|
||||
dom.btnToggleMail2925List.setAttribute('aria-expanded', String(listExpanded));
|
||||
dom.btnToggleMail2925List.disabled = count === 0;
|
||||
}
|
||||
if (dom.mail2925ListShell) {
|
||||
dom.mail2925ListShell.classList.toggle('is-expanded', listExpanded);
|
||||
dom.mail2925ListShell.classList.toggle('is-collapsed', !listExpanded);
|
||||
}
|
||||
}
|
||||
|
||||
function setMail2925ListExpanded(expanded, options = {}) {
|
||||
const { persist = true } = options;
|
||||
listExpanded = Boolean(expanded);
|
||||
updateMail2925ListViewport();
|
||||
if (persist) {
|
||||
localStorage.setItem(expandedStorageKey, listExpanded ? '1' : '0');
|
||||
}
|
||||
}
|
||||
|
||||
function initMail2925ListExpandedState() {
|
||||
const saved = localStorage.getItem(expandedStorageKey);
|
||||
setMail2925ListExpanded(saved === '1', { persist: false });
|
||||
}
|
||||
|
||||
function formatDateTime(timestamp) {
|
||||
const value = Number(timestamp);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return '未记录';
|
||||
}
|
||||
return new Date(value).toLocaleString('zh-CN', {
|
||||
hour12: false,
|
||||
timeZone: displayTimeZone,
|
||||
});
|
||||
}
|
||||
|
||||
function getStatusSnapshot(account) {
|
||||
const status = typeof mail2925Utils.getMail2925AccountStatus === 'function'
|
||||
? mail2925Utils.getMail2925AccountStatus(account, Date.now())
|
||||
: 'ready';
|
||||
switch (status) {
|
||||
case 'cooldown':
|
||||
return { label: '冷却中', className: 'status-used' };
|
||||
case 'disabled':
|
||||
return { label: '已禁用', className: 'status-disabled' };
|
||||
case 'error':
|
||||
return { label: '异常', className: 'status-error' };
|
||||
case 'pending':
|
||||
return { label: '待完善', className: 'status-pending' };
|
||||
default:
|
||||
return { label: '可用', className: 'status-authorized' };
|
||||
}
|
||||
}
|
||||
|
||||
function refreshManagedAliasBaseEmail() {
|
||||
if (typeof helpers.refreshManagedAliasBaseEmail === 'function') {
|
||||
helpers.refreshManagedAliasBaseEmail();
|
||||
}
|
||||
}
|
||||
|
||||
function applyMail2925AccountMutation(account) {
|
||||
if (!account?.id) return;
|
||||
const latestState = state.getLatestState();
|
||||
const currentId = getCurrentMail2925AccountId(latestState);
|
||||
const nextAccounts = typeof mail2925Utils.upsertMail2925AccountInList === 'function'
|
||||
? mail2925Utils.upsertMail2925AccountInList(getMail2925Accounts(latestState), account)
|
||||
: getMail2925Accounts(latestState).map((item) => (item.id === account.id ? account : item));
|
||||
|
||||
const nextState = {
|
||||
mail2925Accounts: nextAccounts,
|
||||
};
|
||||
if (currentId === account.id && account.enabled === false) {
|
||||
nextState.currentMail2925AccountId = null;
|
||||
}
|
||||
state.syncLatestState(nextState);
|
||||
refreshManagedAliasBaseEmail();
|
||||
renderMail2925Accounts();
|
||||
}
|
||||
|
||||
function clearMail2925Form() {
|
||||
if (dom.inputMail2925Email) dom.inputMail2925Email.value = '';
|
||||
if (dom.inputMail2925Password) dom.inputMail2925Password.value = '';
|
||||
}
|
||||
|
||||
const formController = typeof createAccountPoolFormController === 'function'
|
||||
? createAccountPoolFormController({
|
||||
formShell: dom.mail2925FormShell,
|
||||
toggleButton: dom.btnToggleMail2925Form,
|
||||
hiddenLabel: '添加账号',
|
||||
visibleLabel: '取消添加',
|
||||
onClear: () => {
|
||||
stopEditingAccount({ clearForm: true });
|
||||
},
|
||||
onFocus: () => {
|
||||
dom.inputMail2925Email?.focus?.();
|
||||
},
|
||||
})
|
||||
: {
|
||||
isVisible: () => false,
|
||||
setVisible() {},
|
||||
sync() {},
|
||||
};
|
||||
|
||||
function syncEditUi() {
|
||||
if (dom.btnAddMail2925Account) {
|
||||
dom.btnAddMail2925Account.textContent = editingAccountId ? '保存修改' : '添加账号';
|
||||
}
|
||||
}
|
||||
|
||||
function startEditingAccount(account) {
|
||||
if (!account?.id) return;
|
||||
editingAccountId = account.id;
|
||||
if (dom.inputMail2925Email) dom.inputMail2925Email.value = String(account.email || '').trim();
|
||||
if (dom.inputMail2925Password) dom.inputMail2925Password.value = String(account.password || '');
|
||||
formController.setVisible(true, { focusField: false });
|
||||
syncEditUi();
|
||||
}
|
||||
|
||||
function stopEditingAccount(options = {}) {
|
||||
const { clearForm = true } = options;
|
||||
editingAccountId = '';
|
||||
if (clearForm) {
|
||||
clearMail2925Form();
|
||||
}
|
||||
syncEditUi();
|
||||
}
|
||||
|
||||
function renderMail2925Accounts() {
|
||||
if (!dom.mail2925AccountsList) return;
|
||||
|
||||
const latestState = state.getLatestState();
|
||||
const accounts = getMail2925Accounts(latestState);
|
||||
const currentId = getCurrentMail2925AccountId(latestState);
|
||||
|
||||
if (!accounts.length) {
|
||||
dom.mail2925AccountsList.innerHTML = '<div class="hotmail-empty">还没有 2925 账号,先添加一条再使用。</div>';
|
||||
updateMail2925ListViewport();
|
||||
return;
|
||||
}
|
||||
|
||||
dom.mail2925AccountsList.innerHTML = accounts.map((account) => {
|
||||
const status = getStatusSnapshot(account);
|
||||
const coolingDown = status.label === '冷却中';
|
||||
return `
|
||||
<div class="hotmail-account-item${account.id === currentId ? ' is-current' : ''}">
|
||||
<div class="hotmail-account-top">
|
||||
<div class="hotmail-account-title-row">
|
||||
<div class="hotmail-account-email">${helpers.escapeHtml(account.email || '(未命名账号)')}</div>
|
||||
<button
|
||||
class="hotmail-copy-btn"
|
||||
type="button"
|
||||
data-account-action="copy-email"
|
||||
data-account-id="${helpers.escapeHtml(account.id)}"
|
||||
title="复制邮箱"
|
||||
aria-label="复制邮箱 ${helpers.escapeHtml(account.email || '')}"
|
||||
>${copyIcon}</button>
|
||||
</div>
|
||||
<span class="hotmail-status-chip ${helpers.escapeHtml(status.className)}">${helpers.escapeHtml(status.label)}</span>
|
||||
</div>
|
||||
<div class="hotmail-account-meta">
|
||||
<span>密码:${account.password ? '已保存' : '未保存'}</span>
|
||||
<span>上次登录:${helpers.escapeHtml(formatDateTime(account.lastLoginAt))}</span>
|
||||
<span>上次使用:${helpers.escapeHtml(formatDateTime(account.lastUsedAt))}</span>
|
||||
<span>上限记录:${helpers.escapeHtml(formatDateTime(account.lastLimitAt))}</span>
|
||||
<span>恢复时间:${helpers.escapeHtml(formatDateTime(account.disabledUntil))}</span>
|
||||
</div>
|
||||
${account.lastError ? `<div class="hotmail-account-error">${helpers.escapeHtml(account.lastError)}</div>` : ''}
|
||||
<div class="hotmail-account-actions">
|
||||
<button class="btn btn-outline btn-sm" type="button" data-account-action="select" data-account-id="${helpers.escapeHtml(account.id)}">使用此账号</button>
|
||||
<button class="btn btn-primary btn-sm" type="button" data-account-action="login" data-account-id="${helpers.escapeHtml(account.id)}">登录</button>
|
||||
<button class="btn btn-outline btn-sm" type="button" data-account-action="edit" data-account-id="${helpers.escapeHtml(account.id)}">编辑</button>
|
||||
<button class="btn btn-outline btn-sm" type="button" data-account-action="toggle-enabled" data-account-id="${helpers.escapeHtml(account.id)}">${account.enabled === false ? '启用' : '禁用'}</button>
|
||||
${coolingDown ? `<button class="btn btn-outline btn-sm" type="button" data-account-action="clear-cooldown" data-account-id="${helpers.escapeHtml(account.id)}">清冷却</button>` : ''}
|
||||
<button class="btn btn-ghost btn-sm" type="button" data-account-action="delete" data-account-id="${helpers.escapeHtml(account.id)}">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
updateMail2925ListViewport();
|
||||
}
|
||||
|
||||
async function handleAddMail2925Account() {
|
||||
if (actionInFlight) return;
|
||||
|
||||
const email = String(dom.inputMail2925Email?.value || '').trim();
|
||||
const password = String(dom.inputMail2925Password?.value || '');
|
||||
if (!email) {
|
||||
helpers.showToast('请先填写 2925 邮箱。', 'warn');
|
||||
return;
|
||||
}
|
||||
if (!password) {
|
||||
helpers.showToast('请先填写 2925 密码。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const updatingExisting = Boolean(editingAccountId);
|
||||
actionInFlight = true;
|
||||
if (dom.btnAddMail2925Account) {
|
||||
dom.btnAddMail2925Account.disabled = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'UPSERT_MAIL2925_ACCOUNT',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
...(editingAccountId ? { id: editingAccountId } : {}),
|
||||
email,
|
||||
password,
|
||||
},
|
||||
});
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
applyMail2925AccountMutation(response.account);
|
||||
formController.setVisible(false, { clearForm: true });
|
||||
helpers.showToast(
|
||||
updatingExisting
|
||||
? `已更新 2925 账号 ${email}`
|
||||
: `已保存 2925 账号 ${email}`,
|
||||
'success',
|
||||
1800
|
||||
);
|
||||
} catch (err) {
|
||||
helpers.showToast(`保存 2925 账号失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
if (dom.btnAddMail2925Account) {
|
||||
dom.btnAddMail2925Account.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImportMail2925Accounts() {
|
||||
if (actionInFlight) return;
|
||||
if (typeof mail2925Utils.parseMail2925ImportText !== 'function') {
|
||||
helpers.showToast('2925 导入解析器未加载,请刷新扩展后重试。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const rawText = String(dom.inputMail2925Import?.value || '').trim();
|
||||
if (!rawText) {
|
||||
helpers.showToast('请先粘贴 2925 账号导入内容。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedAccounts = mail2925Utils.parseMail2925ImportText(rawText);
|
||||
if (!parsedAccounts.length) {
|
||||
helpers.showToast('没有解析到有效账号,请检查格式是否为 邮箱----密码。', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
actionInFlight = true;
|
||||
if (dom.btnImportMail2925Accounts) {
|
||||
dom.btnImportMail2925Accounts.disabled = true;
|
||||
}
|
||||
|
||||
try {
|
||||
for (const account of parsedAccounts) {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'UPSERT_MAIL2925_ACCOUNT',
|
||||
source: 'sidepanel',
|
||||
payload: account,
|
||||
});
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
}
|
||||
|
||||
if (dom.inputMail2925Import) {
|
||||
dom.inputMail2925Import.value = '';
|
||||
}
|
||||
helpers.showToast(`已导入 ${parsedAccounts.length} 条 2925 账号`, 'success', 2200);
|
||||
} catch (err) {
|
||||
helpers.showToast(`批量导入 2925 账号失败:${err.message}`, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
if (dom.btnImportMail2925Accounts) {
|
||||
dom.btnImportMail2925Accounts.disabled = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllMail2925Accounts() {
|
||||
const accounts = getMail2925Accounts();
|
||||
if (!accounts.length) {
|
||||
helpers.showToast('没有可删除的 2925 账号。', 'warn');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmed = await helpers.openConfirmModal({
|
||||
title: '全部删除 2925 账号',
|
||||
message: `确认删除当前全部 ${accounts.length} 个 2925 账号吗?`,
|
||||
confirmLabel: '确认全部删除',
|
||||
confirmVariant: 'btn-danger',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'DELETE_MAIL2925_ACCOUNTS',
|
||||
source: 'sidepanel',
|
||||
payload: { mode: 'all' },
|
||||
});
|
||||
if (response?.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
state.syncLatestState({
|
||||
mail2925Accounts: [],
|
||||
currentMail2925AccountId: null,
|
||||
});
|
||||
formController.setVisible(false, { clearForm: true });
|
||||
refreshManagedAliasBaseEmail();
|
||||
renderMail2925Accounts();
|
||||
helpers.showToast(`已删除全部 ${response.deletedCount || 0} 个 2925 账号`, 'success', 2200);
|
||||
}
|
||||
|
||||
async function handleAccountListClick(event) {
|
||||
const actionButton = event.target.closest('[data-account-action]');
|
||||
if (!actionButton || actionInFlight) {
|
||||
return;
|
||||
}
|
||||
|
||||
const accountId = String(actionButton.dataset.accountId || '');
|
||||
const action = String(actionButton.dataset.accountAction || '');
|
||||
if (!accountId || !action) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetAccount = getMail2925Accounts().find((account) => account.id === accountId) || null;
|
||||
actionInFlight = true;
|
||||
actionButton.disabled = true;
|
||||
|
||||
try {
|
||||
if (action === 'copy-email') {
|
||||
if (!targetAccount?.email) throw new Error('未找到可复制的 2925 邮箱。');
|
||||
await helpers.copyTextToClipboard(targetAccount.email);
|
||||
helpers.showToast(`已复制 ${targetAccount.email}`, 'success', 1800);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'select') {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'SELECT_MAIL2925_ACCOUNT',
|
||||
source: 'sidepanel',
|
||||
payload: { accountId },
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
state.syncLatestState({ currentMail2925AccountId: response.account.id });
|
||||
refreshManagedAliasBaseEmail();
|
||||
renderMail2925Accounts();
|
||||
helpers.showToast(`已切换当前 2925 账号为 ${response.account.email}`, 'success', 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'login') {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'LOGIN_MAIL2925_ACCOUNT',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
accountId,
|
||||
forceRelogin: true,
|
||||
},
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
state.syncLatestState({ currentMail2925AccountId: response.account.id });
|
||||
refreshManagedAliasBaseEmail();
|
||||
renderMail2925Accounts();
|
||||
helpers.showToast(`已使用 ${response.account.email} 登录 2925 邮箱`, 'success', 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'edit') {
|
||||
if (!targetAccount) throw new Error('未找到目标 2925 账号。');
|
||||
startEditingAccount(targetAccount);
|
||||
helpers.showToast(`已载入 ${targetAccount.email},修改后点“保存修改”即可`, 'info', 1800);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'toggle-enabled') {
|
||||
if (!targetAccount) throw new Error('未找到目标 2925 账号。');
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'PATCH_MAIL2925_ACCOUNT',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
accountId,
|
||||
updates: {
|
||||
enabled: targetAccount.enabled === false,
|
||||
},
|
||||
},
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
applyMail2925AccountMutation(response.account);
|
||||
helpers.showToast(`2925 账号 ${response.account.email} 已${response.account.enabled === false ? '禁用' : '启用'}`, 'success', 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'clear-cooldown') {
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'PATCH_MAIL2925_ACCOUNT',
|
||||
source: 'sidepanel',
|
||||
payload: {
|
||||
accountId,
|
||||
updates: {
|
||||
disabledUntil: 0,
|
||||
lastError: '',
|
||||
},
|
||||
},
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
applyMail2925AccountMutation(response.account);
|
||||
helpers.showToast(`2925 账号 ${response.account.email} 已清除冷却`, 'success', 2200);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'delete') {
|
||||
const confirmed = await helpers.openConfirmModal({
|
||||
title: '删除 2925 账号',
|
||||
message: '确认删除这个 2925 账号吗?',
|
||||
confirmLabel: '确认删除',
|
||||
confirmVariant: 'btn-danger',
|
||||
});
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
const response = await runtime.sendMessage({
|
||||
type: 'DELETE_MAIL2925_ACCOUNT',
|
||||
source: 'sidepanel',
|
||||
payload: { accountId },
|
||||
});
|
||||
if (response?.error) throw new Error(response.error);
|
||||
|
||||
const nextAccounts = getMail2925Accounts().filter((account) => account.id !== accountId);
|
||||
const nextState = { mail2925Accounts: nextAccounts };
|
||||
if (getCurrentMail2925AccountId() === accountId) {
|
||||
nextState.currentMail2925AccountId = null;
|
||||
}
|
||||
state.syncLatestState(nextState);
|
||||
if (editingAccountId === accountId) {
|
||||
formController.setVisible(false, { clearForm: true });
|
||||
}
|
||||
refreshManagedAliasBaseEmail();
|
||||
renderMail2925Accounts();
|
||||
helpers.showToast('2925 账号已删除', 'success', 1800);
|
||||
}
|
||||
} catch (err) {
|
||||
helpers.showToast(err.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
actionButton.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function bindMail2925Events() {
|
||||
dom.btnToggleMail2925List?.addEventListener('click', () => {
|
||||
setMail2925ListExpanded(!listExpanded);
|
||||
});
|
||||
|
||||
dom.btnToggleMail2925Form?.addEventListener('click', () => {
|
||||
if (formController.isVisible()) {
|
||||
formController.setVisible(false, { clearForm: true });
|
||||
return;
|
||||
}
|
||||
formController.setVisible(true, { clearForm: !editingAccountId, focusField: true });
|
||||
});
|
||||
|
||||
dom.btnDeleteAllMail2925Accounts?.addEventListener('click', async () => {
|
||||
if (actionInFlight) return;
|
||||
actionInFlight = true;
|
||||
try {
|
||||
await deleteAllMail2925Accounts();
|
||||
} catch (err) {
|
||||
helpers.showToast(err.message, 'error');
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
updateMail2925ListViewport();
|
||||
}
|
||||
});
|
||||
|
||||
dom.btnAddMail2925Account?.addEventListener('click', handleAddMail2925Account);
|
||||
dom.btnImportMail2925Accounts?.addEventListener('click', handleImportMail2925Accounts);
|
||||
dom.mail2925AccountsList?.addEventListener('click', handleAccountListClick);
|
||||
syncEditUi();
|
||||
formController.sync();
|
||||
}
|
||||
|
||||
return {
|
||||
bindMail2925Events,
|
||||
initMail2925ListExpandedState,
|
||||
renderMail2925Accounts,
|
||||
};
|
||||
}
|
||||
|
||||
globalScope.SidepanelMail2925Manager = {
|
||||
createMail2925Manager,
|
||||
};
|
||||
})(window);
|
||||
+267
-61
@@ -100,7 +100,9 @@ body {
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 200;
|
||||
@@ -228,12 +230,91 @@ header {
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.btn-contribution-mode {
|
||||
padding-inline: 10px;
|
||||
}
|
||||
|
||||
.btn-contribution-mode.is-active {
|
||||
border-color: color-mix(in srgb, var(--orange) 58%, var(--border));
|
||||
background: color-mix(in srgb, var(--orange) 14%, var(--bg-base));
|
||||
color: var(--orange);
|
||||
}
|
||||
|
||||
.contribution-update-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 1400;
|
||||
}
|
||||
|
||||
.contribution-update-hint {
|
||||
--contribution-update-arrow-left: 20px;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
max-width: min(220px, calc(100vw - 24px));
|
||||
padding: 8px 10px;
|
||||
background: color-mix(in srgb, var(--amber) 10%, var(--bg-base));
|
||||
border: 1px solid color-mix(in srgb, var(--amber) 34%, var(--border));
|
||||
border-radius: 10px;
|
||||
box-shadow: var(--shadow-sm);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.contribution-update-hint::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -7px;
|
||||
left: calc(var(--contribution-update-arrow-left) - 6px);
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: inherit;
|
||||
border-top: inherit;
|
||||
border-left: inherit;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.contribution-update-hint-text {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.contribution-update-hint-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: background var(--transition), color var(--transition);
|
||||
}
|
||||
|
||||
.contribution-update-hint-close:hover {
|
||||
background: color-mix(in srgb, var(--amber) 16%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.contribution-update-hint-close:focus-visible {
|
||||
outline: 2px solid color-mix(in srgb, var(--amber) 34%, transparent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Theme Toggle
|
||||
============================================================ */
|
||||
@@ -541,6 +622,108 @@ header {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.is-contribution-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.contribution-mode-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid color-mix(in srgb, var(--orange) 28%, var(--border));
|
||||
border-radius: var(--radius-md);
|
||||
background:
|
||||
linear-gradient(180deg,
|
||||
color-mix(in srgb, var(--orange) 10%, var(--bg-base)),
|
||||
color-mix(in srgb, var(--bg-surface) 96%, var(--bg-base))
|
||||
);
|
||||
}
|
||||
|
||||
.contribution-mode-panel[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.contribution-mode-panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contribution-mode-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--orange) 16%, var(--bg-base));
|
||||
color: var(--orange);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.contribution-mode-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.contribution-mode-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contribution-mode-status-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--bg-base) 92%, var(--orange-soft));
|
||||
}
|
||||
|
||||
.contribution-mode-status-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.contribution-mode-status-value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.5;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.contribution-mode-summary {
|
||||
padding: 10px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: color-mix(in srgb, var(--bg-base) 88%, var(--orange-soft));
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.contribution-mode-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contribution-mode-actions .btn {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.contribution-mode-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.section-mini-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -586,6 +769,21 @@ header {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail2925-base-inline {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.mail2925-base-input,
|
||||
.mail2925-pool-select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mail2925-pool-toggle {
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-value-actions {
|
||||
justify-content: space-between;
|
||||
}
|
||||
@@ -608,6 +806,17 @@ header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#row-codex2api-url .data-label,
|
||||
#row-codex2api-admin-key .data-label {
|
||||
width: 76px;
|
||||
}
|
||||
|
||||
#row-codex2api-url .data-label {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.data-value {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
@@ -916,6 +1125,20 @@ header {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.account-pool-form-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.account-pool-actions-inline {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.account-pool-import-action {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.hotmail-import-row {
|
||||
align-items: flex-start;
|
||||
}
|
||||
@@ -938,7 +1161,7 @@ header {
|
||||
}
|
||||
|
||||
.hotmail-list-shell.is-collapsed {
|
||||
max-height: 236px;
|
||||
max-height: 176px;
|
||||
}
|
||||
|
||||
.hotmail-list-shell.is-expanded {
|
||||
@@ -1284,36 +1507,51 @@ header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.fallback-inline {
|
||||
justify-content: space-between;
|
||||
.setting-pair {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.fallback-thread-interval {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.timing-field {
|
||||
.setting-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.timing-field-labeled {
|
||||
min-width: 196px;
|
||||
justify-content: flex-end;
|
||||
.setting-group-primary {
|
||||
flex: 1;
|
||||
min-width: 112px;
|
||||
}
|
||||
|
||||
.timing-field-right {
|
||||
.setting-group-secondary {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.setting-inline-right {
|
||||
min-width: 196px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.setting-caption {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.setting-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
@@ -1371,51 +1609,6 @@ header {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.auto-delay-inline {
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.auto-delay-side {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.auto-delay-side-right {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.auto-delay-check {
|
||||
flex: 0 0 auto;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.auto-delay-check span {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.auto-delay-caption {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
flex: 0 0 auto;
|
||||
white-space: nowrap;
|
||||
min-width: 56px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.auto-delay-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.auto-delay-input {
|
||||
width: 72px;
|
||||
flex: 0 0 auto;
|
||||
@@ -2211,6 +2404,19 @@ header {
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 14px;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.modal-message a,
|
||||
.modal-alert a {
|
||||
color: var(--blue);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.modal-message a:hover,
|
||||
.modal-alert a:hover {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.modal-alert {
|
||||
|
||||
+197
-50
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>多页面自动化面板</title>
|
||||
<title>codex-oauth-automation-extension</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link
|
||||
@@ -35,8 +35,8 @@
|
||||
<div class="header-btns">
|
||||
<div class="run-group">
|
||||
<button id="btn-contribution-mode" class="btn btn-outline btn-sm btn-contribution-mode" type="button"
|
||||
title="打开账号贡献上传页面">贡献</button>
|
||||
<input type="number" id="input-run-count" class="run-count-input" value="1" min="1" max="50" title="运行次数" />
|
||||
aria-pressed="false" title="进入贡献模式并打开上传页">贡献/使用</button>
|
||||
<input type="number" id="input-run-count" class="run-count-input" value="1" min="1" title="运行次数" />
|
||||
<button id="btn-auto-run" class="btn btn-success" title="自动执行全部步骤">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor">
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
@@ -81,6 +81,16 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="contribution-update-layer" class="contribution-update-layer" hidden>
|
||||
<div id="contribution-update-hint" class="contribution-update-hint" aria-live="polite" hidden>
|
||||
<p id="contribution-update-hint-text" class="contribution-update-hint-text">
|
||||
公告 / 使用教程有更新了,可点上方“贡献/使用”查看。
|
||||
</p>
|
||||
<button id="btn-dismiss-contribution-update-hint" class="contribution-update-hint-close" type="button"
|
||||
aria-label="关闭更新提示" title="关闭更新提示">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section id="update-section" hidden>
|
||||
<div class="update-card">
|
||||
<div class="update-card-header">
|
||||
@@ -102,8 +112,40 @@
|
||||
<select id="select-panel-mode" class="data-select">
|
||||
<option value="cpa">CPA 面板</option>
|
||||
<option value="sub2api">SUB2API</option>
|
||||
<option value="codex2api">Codex2API</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="contribution-mode-panel" class="contribution-mode-panel" hidden>
|
||||
<div class="contribution-mode-panel-header">
|
||||
<span class="section-label">贡献模式</span>
|
||||
<span class="contribution-mode-badge">CPA</span>
|
||||
</div>
|
||||
<p id="contribution-mode-text" class="contribution-mode-text">当前账号将用于支持项目维护。扩展会自动申请贡献登录地址并持续跟踪授权状态;如检测到回调地址,会自动提交,无需手动复制。</p>
|
||||
<div class="data-row contribution-mode-field">
|
||||
<span class="data-label">贡献昵称</span>
|
||||
<input type="text" id="input-contribution-nickname" class="data-input" placeholder="可留空,将显示为匿名贡献者" />
|
||||
</div>
|
||||
<div class="data-row contribution-mode-field">
|
||||
<span class="data-label">QQ</span>
|
||||
<input type="text" id="input-contribution-qq" class="data-input" inputmode="numeric" placeholder="可留空,只能填写数字" />
|
||||
</div>
|
||||
<div class="contribution-mode-status-grid">
|
||||
<div class="contribution-mode-status-card">
|
||||
<span class="contribution-mode-status-label">OAUTH</span>
|
||||
<span id="contribution-oauth-status" class="contribution-mode-status-value">未生成登录地址</span>
|
||||
</div>
|
||||
<div class="contribution-mode-status-card">
|
||||
<span class="contribution-mode-status-label">回调</span>
|
||||
<span id="contribution-callback-status" class="contribution-mode-status-value">等待回调</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="contribution-mode-summary" class="contribution-mode-summary">等待开始贡献</div>
|
||||
<div class="contribution-mode-actions">
|
||||
<button id="btn-start-contribution" class="btn btn-primary btn-sm" type="button">开始贡献</button>
|
||||
<button id="btn-open-contribution-upload" class="btn btn-outline btn-sm" type="button">已有认证文件?前往上传</button>
|
||||
<button id="btn-exit-contribution-mode" class="btn btn-ghost btn-sm" type="button">退出贡献模式</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-vps-url">
|
||||
<span class="data-label">CPA</span>
|
||||
<div class="input-with-icon">
|
||||
@@ -123,10 +165,10 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-local-cpa-step9-mode">
|
||||
<span class="data-label">本地 CPA</span>
|
||||
<div id="local-cpa-step9-mode-group" class="choice-group" role="group" aria-label="本地 CPA 第 10 步策略">
|
||||
<button type="button" class="choice-btn" data-local-cpa-step9-mode="submit">全部回调</button>
|
||||
<button type="button" class="choice-btn" data-local-cpa-step9-mode="bypass">跳过第10步</button>
|
||||
<span class="data-label">回调方式</span>
|
||||
<div id="local-cpa-step9-mode-group" class="choice-group" role="group" aria-label="回调方式">
|
||||
<button type="button" class="choice-btn" data-local-cpa-step9-mode="submit">服务器部署</button>
|
||||
<button type="button" class="choice-btn" data-local-cpa-step9-mode="bypass">本地部署</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-sub2api-url" style="display:none;">
|
||||
@@ -151,7 +193,17 @@
|
||||
<input type="text" id="input-sub2api-default-proxy" class="data-input"
|
||||
placeholder="留空则不使用代理;或填写代理名称 / ID" />
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<div class="data-row" id="row-codex2api-url" style="display:none;">
|
||||
<span class="data-label">Codex2API</span>
|
||||
<input type="text" id="input-codex2api-url" class="data-input"
|
||||
placeholder="http://localhost:8080/admin/accounts" />
|
||||
</div>
|
||||
<div class="data-row" id="row-codex2api-admin-key" style="display:none;">
|
||||
<span class="data-label">管理密钥</span>
|
||||
<input type="password" id="input-codex2api-admin-key" class="data-input"
|
||||
placeholder="请输入 Codex2API Admin Secret" />
|
||||
</div>
|
||||
<div class="data-row" id="row-custom-password">
|
||||
<span class="data-label">账户密码</span>
|
||||
<div class="input-with-icon">
|
||||
<input type="password" id="input-password" class="data-input data-input-with-icon"
|
||||
@@ -195,31 +247,6 @@
|
||||
<option value="cloudflare-temp-email">Cloudflare Temp Email</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-base-url" style="display:none;">
|
||||
<span class="data-label">Temp API</span>
|
||||
<input type="text" id="input-temp-email-base-url" class="data-input" placeholder="https://your-worker-domain" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-admin-auth" style="display:none;">
|
||||
<span class="data-label">Admin Auth</span>
|
||||
<input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon" placeholder="Cloudflare Temp Email admin password" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-custom-auth" style="display:none;">
|
||||
<span class="data-label">Custom Auth</span>
|
||||
<input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon" placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-receive-mailbox" style="display:none;">
|
||||
<span class="data-label">邮件接收</span>
|
||||
<input type="text" id="input-temp-email-receive-mailbox" class="data-input" placeholder="用于接收转发邮件的邮箱,例如 1@email.example.com" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-domain" style="display:none;">
|
||||
<span class="data-label">Temp 域名</span>
|
||||
<div class="data-inline">
|
||||
<select id="select-temp-email-domain" class="data-select"></select>
|
||||
<input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com"
|
||||
style="display:none;" />
|
||||
<button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-cf-domain" style="display:none;">
|
||||
<span class="data-label">CF 域名</span>
|
||||
<div class="data-inline">
|
||||
@@ -229,9 +256,26 @@
|
||||
<button id="btn-cf-domain-mode" class="btn btn-outline btn-sm data-inline-btn" type="button">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-mail2925-pool-settings" style="display:none;">
|
||||
<span class="data-label">2925 号池</span>
|
||||
<div class="data-inline mail2925-base-inline">
|
||||
<label class="toggle-switch mail2925-pool-toggle" id="label-mail2925-use-account-pool" for="input-mail2925-use-account-pool" style="display:none;" title="开启后启用 2925 账号池">
|
||||
<input type="checkbox" id="input-mail2925-use-account-pool" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
<span>号池</span>
|
||||
</label>
|
||||
<select id="select-mail2925-pool-account" class="data-select mail2925-pool-select" style="display:none;">
|
||||
<option value="">请选择号池邮箱</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-email-prefix" style="display:none;">
|
||||
<span class="data-label" id="label-email-prefix">别名基邮箱</span>
|
||||
<input type="text" id="input-email-prefix" class="data-input" placeholder="例如 yourname@example.com" />
|
||||
<div class="data-inline">
|
||||
<input type="text" id="input-email-prefix" class="data-input mail2925-base-input" placeholder="例如 yourname@example.com" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-inbucket-host" style="display:none;">
|
||||
<span class="data-label">Inbucket</span>
|
||||
@@ -250,23 +294,23 @@
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">延迟</span>
|
||||
<div class="data-inline auto-delay-inline">
|
||||
<div class="auto-delay-side auto-delay-side-left">
|
||||
<div class="data-inline setting-pair">
|
||||
<div class="setting-group setting-group-primary">
|
||||
<label class="toggle-switch" for="input-auto-delay-enabled">
|
||||
<input type="checkbox" id="input-auto-delay-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="auto-delay-controls">
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-auto-delay-minutes" class="data-input auto-delay-input" value="30" min="1"
|
||||
max="1440" step="1" title="启动前倒计时分钟数" />
|
||||
<span class="data-unit">分钟</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timing-field timing-field-labeled timing-field-right auto-delay-side auto-delay-side-right">
|
||||
<span class="auto-delay-caption">步间间隔</span>
|
||||
<div class="auto-delay-controls">
|
||||
<div class="setting-group setting-group-secondary">
|
||||
<span class="setting-caption">步间间隔</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-auto-step-delay-seconds" class="data-input auto-delay-input" value=""
|
||||
min="0" max="600" step="1" title="步间延迟秒数,0 或留空则不延迟" />
|
||||
<span class="data-unit">秒</span>
|
||||
@@ -276,16 +320,18 @@
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">自动重试</span>
|
||||
<div class="data-inline fallback-inline">
|
||||
<div class="data-inline setting-pair">
|
||||
<div class="setting-group setting-group-primary">
|
||||
<label class="toggle-switch" for="input-auto-skip-failures">
|
||||
<input type="checkbox" id="input-auto-skip-failures" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="timing-field timing-field-labeled timing-field-right fallback-thread-interval">
|
||||
<span class="auto-delay-caption">线程间隔</span>
|
||||
<div class="auto-delay-controls">
|
||||
</div>
|
||||
<div class="setting-group setting-group-secondary">
|
||||
<span class="setting-caption">线程间隔</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-auto-skip-failures-thread-interval-minutes" class="data-input auto-delay-input" value="0"
|
||||
min="0" max="1440" step="1" title="兜底模式下,两轮线程之间的等待分钟数" />
|
||||
<span class="data-unit">分钟</span>
|
||||
@@ -293,18 +339,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<div class="data-row" id="row-account-run-history-text-enabled">
|
||||
<span class="data-label">本地同步</span>
|
||||
<div class="data-inline auto-delay-inline">
|
||||
<div class="data-inline setting-pair">
|
||||
<div class="setting-group setting-group-primary">
|
||||
<label class="toggle-switch" for="input-account-run-history-text-enabled">
|
||||
<input type="checkbox" id="input-account-run-history-text-enabled" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
<div class="timing-field timing-field-right setting-inline-right">
|
||||
<span class="auto-delay-caption">验证码重发</span>
|
||||
<div class="auto-delay-controls">
|
||||
</div>
|
||||
<div class="setting-group setting-group-secondary">
|
||||
<span class="setting-caption">验证码重发</span>
|
||||
<div class="setting-controls">
|
||||
<input type="number" id="input-verification-resend-count" class="data-input auto-delay-input" value="4"
|
||||
min="0" max="20" step="1" title="自动点击重新发送验证码的次数" />
|
||||
<span class="data-unit">次</span>
|
||||
@@ -328,12 +376,63 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="cloudflare-temp-email-section" class="data-card hotmail-card" style="display:none;">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">Cloudflare Temp Email</span>
|
||||
<span class="data-value">用于生成邮箱或接收转发邮件</span>
|
||||
</div>
|
||||
<div class="section-mini-actions">
|
||||
<button id="btn-cloudflare-temp-email-usage-guide" class="btn btn-ghost btn-xs" type="button">使用教程</button>
|
||||
<button id="btn-cloudflare-temp-email-github" class="btn btn-ghost btn-xs" type="button">GitHub</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-base-url" style="display:none;">
|
||||
<span class="data-label">Temp API</span>
|
||||
<input type="text" id="input-temp-email-base-url" class="data-input" placeholder="https://your-worker-domain" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-admin-auth" style="display:none;">
|
||||
<span class="data-label">Admin Auth</span>
|
||||
<input type="password" id="input-temp-email-admin-auth" class="data-input data-input-with-icon" placeholder="Cloudflare Temp Email admin password" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-custom-auth" style="display:none;">
|
||||
<span class="data-label">Custom Auth</span>
|
||||
<input type="password" id="input-temp-email-custom-auth" class="data-input data-input-with-icon" placeholder="仅当站点启用了访问密码时再填写;这是额外鉴权,不替代 Admin Auth。" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-receive-mailbox" style="display:none;">
|
||||
<span class="data-label">邮件接收</span>
|
||||
<input type="text" id="input-temp-email-receive-mailbox" class="data-input" placeholder="用于接收转发邮件的邮箱,例如 1@email.example.com" />
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-random-subdomain-toggle" style="display:none;">
|
||||
<span class="data-label">随机子域</span>
|
||||
<div class="data-inline">
|
||||
<label class="toggle-switch" for="input-temp-email-use-random-subdomain" title="依赖后端 RANDOM_SUBDOMAIN_DOMAINS 配置">
|
||||
<input type="checkbox" id="input-temp-email-use-random-subdomain" />
|
||||
<span class="toggle-switch-track" aria-hidden="true">
|
||||
<span class="toggle-switch-thumb"></span>
|
||||
</span>
|
||||
<span>启用</span>
|
||||
</label>
|
||||
<span class="setting-caption">依赖后端 RANDOM_SUBDOMAIN_DOMAINS</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row" id="row-temp-email-domain" style="display:none;">
|
||||
<span class="data-label">Temp 域名</span>
|
||||
<div class="data-inline">
|
||||
<select id="select-temp-email-domain" class="data-select"></select>
|
||||
<input type="text" id="input-temp-email-domain" class="data-input" placeholder="例如 mail.example.com"
|
||||
style="display:none;" />
|
||||
<button id="btn-temp-email-domain-mode" class="btn btn-outline btn-sm" type="button">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hotmail-section" class="data-card hotmail-card" style="display:none;">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">Hotmail 账号池</span>
|
||||
</div>
|
||||
<div class="section-mini-actions">
|
||||
<button id="btn-toggle-hotmail-form" class="btn btn-outline btn-xs" type="button" aria-expanded="false">添加账号</button>
|
||||
<button id="btn-hotmail-usage-guide" class="btn btn-ghost btn-xs" type="button">使用教程</button>
|
||||
<button id="btn-clear-used-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">清空已用</button>
|
||||
<button id="btn-delete-all-hotmail-accounts" class="btn btn-ghost btn-xs" type="button">全部删除</button>
|
||||
@@ -356,6 +455,7 @@
|
||||
<span class="data-label">本地助手</span>
|
||||
<input type="text" id="input-hotmail-local-base-url" class="data-input mono" placeholder="http://127.0.0.1:17373" />
|
||||
</div>
|
||||
<div id="hotmail-form-shell" class="account-pool-form-shell" hidden>
|
||||
<div class="data-row">
|
||||
<span class="data-label">邮箱</span>
|
||||
<input type="text" id="input-hotmail-email" class="data-input" placeholder="name@hotmail.com" />
|
||||
@@ -375,20 +475,62 @@
|
||||
</div>
|
||||
<div class="data-row hotmail-actions-row">
|
||||
<span class="data-label"></span>
|
||||
<div class="data-inline account-pool-actions-inline">
|
||||
<button id="btn-add-hotmail-account" class="btn btn-primary btn-sm" type="button">添加账号</button>
|
||||
<button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm account-pool-import-action" type="button">批量导入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row hotmail-import-row">
|
||||
<span class="data-label">批量导入</span>
|
||||
<div class="hotmail-import-box">
|
||||
<textarea id="input-hotmail-import" class="data-textarea mono"
|
||||
placeholder="账号----密码----客户端ID----刷新令牌 name@hotmail.com----password----client-id----refresh-token"></textarea>
|
||||
<button id="btn-import-hotmail-accounts" class="btn btn-outline btn-sm" type="button">导入</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hotmail-list-shell" class="hotmail-list-shell is-collapsed">
|
||||
<div id="hotmail-accounts-list" class="hotmail-accounts-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mail2925-section" class="data-card hotmail-card" style="display:none;">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
<span class="section-label">2925 账号池</span>
|
||||
</div>
|
||||
<div class="section-mini-actions">
|
||||
<button id="btn-toggle-mail2925-form" class="btn btn-outline btn-xs" type="button" aria-expanded="false">添加账号</button>
|
||||
<button id="btn-delete-all-mail2925-accounts" class="btn btn-ghost btn-xs" type="button">全部删除</button>
|
||||
<button id="btn-toggle-mail2925-list" class="btn btn-ghost btn-xs" type="button" aria-expanded="false">展开列表</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mail2925-form-shell" class="account-pool-form-shell" hidden>
|
||||
<div class="data-row">
|
||||
<span class="data-label">邮箱</span>
|
||||
<input type="text" id="input-mail2925-email" class="data-input" placeholder="name@2925.com" />
|
||||
</div>
|
||||
<div class="data-row">
|
||||
<span class="data-label">密码</span>
|
||||
<input type="password" id="input-mail2925-password" class="data-input" placeholder="2925 登录密码" />
|
||||
</div>
|
||||
<div class="data-row hotmail-actions-row">
|
||||
<span class="data-label"></span>
|
||||
<div class="data-inline account-pool-actions-inline">
|
||||
<button id="btn-add-mail2925-account" class="btn btn-primary btn-sm" type="button">添加账号</button>
|
||||
<button id="btn-import-mail2925-accounts" class="btn btn-outline btn-sm account-pool-import-action" type="button">批量导入</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="data-row hotmail-import-row">
|
||||
<span class="data-label">批量导入</span>
|
||||
<div class="hotmail-import-box">
|
||||
<textarea id="input-mail2925-import" class="data-textarea mono"
|
||||
placeholder="邮箱----密码 name@2925.com----password"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="mail2925-list-shell" class="hotmail-list-shell is-collapsed">
|
||||
<div id="mail2925-accounts-list" class="hotmail-accounts-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="luckmail-section" class="data-card luckmail-card" style="display:none;">
|
||||
<div class="section-mini-header">
|
||||
<div class="section-mini-copy">
|
||||
@@ -621,14 +763,19 @@
|
||||
<div id="toast-container"></div>
|
||||
<input id="input-import-settings-file" type="file" accept=".json,application/json" hidden />
|
||||
<script src="../managed-alias-utils.js"></script>
|
||||
<script src="../mail2925-utils.js"></script>
|
||||
<script src="../icloud-utils.js"></script>
|
||||
<script src="../hotmail-utils.js"></script>
|
||||
<script src="../luckmail-utils.js"></script>
|
||||
<script src="../data/step-definitions.js"></script>
|
||||
<script src="update-service.js"></script>
|
||||
<script src="contribution-content-update-service.js"></script>
|
||||
<script src="account-pool-ui.js"></script>
|
||||
<script src="hotmail-manager.js"></script>
|
||||
<script src="mail-2925-manager.js"></script>
|
||||
<script src="icloud-manager.js"></script>
|
||||
<script src="luckmail-manager.js"></script>
|
||||
<script src="contribution-mode.js"></script>
|
||||
<script src="account-records-manager.js"></script>
|
||||
<script src="sidepanel.js"></script>
|
||||
</body>
|
||||
|
||||
+799
-61
File diff suppressed because it is too large
Load Diff
@@ -81,6 +81,16 @@ test('isRecoverableStep9AuthFailure matches timeout and CPA auth failure statuse
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isRecoverableStep9AuthFailure('提交回调失败:请更新CLI Proxy API或检查连接'),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isRecoverableStep9AuthFailure('认证失败:Bad Request'),
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isRecoverableStep9AuthFailure('认证成功!'),
|
||||
false
|
||||
|
||||
@@ -18,11 +18,11 @@ function createRetryButton() {
|
||||
function createRecoveryApi(state) {
|
||||
const retryButton = createRetryButton();
|
||||
global.location = {
|
||||
pathname: '/log-in',
|
||||
href: 'https://auth.openai.com/log-in',
|
||||
pathname: state.pathname || '/log-in',
|
||||
href: state.href || `https://auth.openai.com${state.pathname || '/log-in'}`,
|
||||
};
|
||||
global.document = {
|
||||
title: 'Something went wrong',
|
||||
title: state.title ?? 'Something went wrong',
|
||||
querySelector(selector) {
|
||||
if (selector === 'button[data-dd-action-name="Try again"]' && state.retryVisible) {
|
||||
return retryButton;
|
||||
@@ -42,6 +42,7 @@ function createRecoveryApi(state) {
|
||||
isActionEnabled: (element) => Boolean(element) && !element.disabled && element.getAttribute('aria-disabled') !== 'true',
|
||||
isVisibleElement: () => true,
|
||||
log: () => {},
|
||||
routeErrorPattern: /405\s+method\s+not\s+allowed|route\s+error.*405/i,
|
||||
simulateClick: () => {
|
||||
state.clickCount += 1;
|
||||
if (typeof state.onClick === 'function') {
|
||||
@@ -78,9 +79,30 @@ test('auth page recovery detects retry page state', () => {
|
||||
assert.equal(snapshot.retryEnabled, true);
|
||||
assert.equal(snapshot.titleMatched, true);
|
||||
assert.equal(snapshot.detailMatched, false);
|
||||
assert.equal(snapshot.routeErrorMatched, false);
|
||||
assert.equal(snapshot.maxCheckAttemptsBlocked, false);
|
||||
});
|
||||
|
||||
test('auth page recovery detects route error retry page on email verification route', () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
pageText: 'Route Error (405 Method Not Allowed): email-verification action missing.',
|
||||
pathname: '/email-verification',
|
||||
retryVisible: true,
|
||||
title: '',
|
||||
};
|
||||
const api = createRecoveryApi(state);
|
||||
|
||||
const snapshot = api.getAuthTimeoutErrorPageState({
|
||||
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
|
||||
});
|
||||
|
||||
assert.equal(Boolean(snapshot), true);
|
||||
assert.equal(snapshot.titleMatched, false);
|
||||
assert.equal(snapshot.detailMatched, false);
|
||||
assert.equal(snapshot.routeErrorMatched, true);
|
||||
});
|
||||
|
||||
test('auth page recovery clicks retry and waits until page recovers', async () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
@@ -182,3 +204,25 @@ test('auth page recovery throws cloudflare security blocked error on max_check_a
|
||||
);
|
||||
});
|
||||
|
||||
test('auth page recovery throws signup user already exists error without clicking retry', async () => {
|
||||
const state = {
|
||||
clickCount: 0,
|
||||
pageText: 'Something went wrong. user_already_exists.',
|
||||
pathname: '/email-verification',
|
||||
retryVisible: true,
|
||||
};
|
||||
const api = createRecoveryApi(state);
|
||||
|
||||
await assert.rejects(
|
||||
() => api.recoverAuthRetryPage({
|
||||
logLabel: '步骤 4:检测到注册认证重试页,正在点击“重试”恢复',
|
||||
pathPatterns: [/\/email-verification(?:[/?#]|$)/i],
|
||||
step: 4,
|
||||
timeoutMs: 1000,
|
||||
}),
|
||||
/SIGNUP_USER_ALREADY_EXISTS::/
|
||||
);
|
||||
|
||||
assert.equal(state.clickCount, 0);
|
||||
});
|
||||
|
||||
|
||||
@@ -168,3 +168,166 @@ test('auto-run controller skips add-phone failures to the next round instead of
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
test('auto-run controller skips user_already_exists failures to the next round instead of retrying the same round', async () => {
|
||||
const events = {
|
||||
logs: [],
|
||||
broadcasts: [],
|
||||
accountRecords: [],
|
||||
runCalls: 0,
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
stepStatuses: {},
|
||||
vpsUrl: 'https://example.com/vps',
|
||||
vpsPassword: 'secret',
|
||||
customPassword: '',
|
||||
autoRunSkipFailures: true,
|
||||
autoRunFallbackThreadIntervalMinutes: 0,
|
||||
autoRunDelayEnabled: false,
|
||||
autoRunDelayMinutes: 30,
|
||||
autoStepDelaySeconds: null,
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'duck',
|
||||
gmailBaseEmail: '',
|
||||
mail2925BaseEmail: '',
|
||||
emailPrefix: 'demo',
|
||||
inbucketHost: '',
|
||||
inbucketMailbox: '',
|
||||
cloudflareDomain: '',
|
||||
cloudflareDomains: [],
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
autoRunRoundSummaries: [],
|
||||
};
|
||||
|
||||
const runtime = {
|
||||
state: {
|
||||
autoRunActive: false,
|
||||
autoRunCurrentRun: 0,
|
||||
autoRunTotalRuns: 1,
|
||||
autoRunAttemptRun: 0,
|
||||
autoRunSessionId: 0,
|
||||
},
|
||||
get() {
|
||||
return { ...this.state };
|
||||
},
|
||||
set(updates = {}) {
|
||||
this.state = { ...this.state, ...updates };
|
||||
},
|
||||
};
|
||||
|
||||
let sessionSeed = 0;
|
||||
|
||||
const controller = api.createAutoRunController({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
appendAccountRunRecord: async (status, _state, reason) => {
|
||||
events.accountRecords.push({ status, reason });
|
||||
return { status, reason };
|
||||
},
|
||||
AUTO_RUN_MAX_RETRIES_PER_ROUND: 3,
|
||||
AUTO_RUN_RETRY_DELAY_MS: 3000,
|
||||
AUTO_RUN_TIMER_KIND_BEFORE_RETRY: 'before_retry',
|
||||
AUTO_RUN_TIMER_KIND_BETWEEN_ROUNDS: 'between_rounds',
|
||||
broadcastAutoRunStatus: async (phase, payload = {}) => {
|
||||
events.broadcasts.push({ phase, ...payload });
|
||||
currentState = {
|
||||
...currentState,
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? runtime.state.autoRunCurrentRun,
|
||||
autoRunTotalRuns: payload.totalRuns ?? runtime.state.autoRunTotalRuns,
|
||||
autoRunAttemptRun: payload.attemptRun ?? runtime.state.autoRunAttemptRun,
|
||||
autoRunSessionId: payload.sessionId ?? runtime.state.autoRunSessionId,
|
||||
};
|
||||
},
|
||||
broadcastStopToContentScripts: async () => {},
|
||||
cancelPendingCommands: () => {},
|
||||
clearStopRequest: () => {},
|
||||
createAutoRunSessionId: () => {
|
||||
sessionSeed += 1;
|
||||
return sessionSeed;
|
||||
},
|
||||
getAutoRunStatusPayload: (phase, payload = {}) => ({
|
||||
autoRunning: ['scheduled', 'running', 'waiting_step', 'waiting_email', 'retrying', 'waiting_interval'].includes(phase),
|
||||
autoRunPhase: phase,
|
||||
autoRunCurrentRun: payload.currentRun ?? 0,
|
||||
autoRunTotalRuns: payload.totalRuns ?? 1,
|
||||
autoRunAttemptRun: payload.attemptRun ?? 0,
|
||||
autoRunSessionId: payload.sessionId ?? 0,
|
||||
}),
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getFirstUnfinishedStep: () => 1,
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getRunningSteps: () => [],
|
||||
getState: async () => ({
|
||||
...currentState,
|
||||
stepStatuses: { ...(currentState.stepStatuses || {}) },
|
||||
tabRegistry: { ...(currentState.tabRegistry || {}) },
|
||||
sourceLastUrls: { ...(currentState.sourceLastUrls || {}) },
|
||||
}),
|
||||
getStopRequested: () => false,
|
||||
hasSavedProgress: () => false,
|
||||
isAddPhoneAuthFailure: () => false,
|
||||
isRestartCurrentAttemptError: () => false,
|
||||
isSignupUserAlreadyExistsFailure: (error) => /SIGNUP_USER_ALREADY_EXISTS::|user_already_exists/i.test(error?.message || String(error || '')),
|
||||
isStopError: (error) => (error?.message || String(error || '')) === '流程已被用户停止。',
|
||||
launchAutoRunTimerPlan: async () => false,
|
||||
normalizeAutoRunFallbackThreadIntervalMinutes: (value) => Math.max(0, Math.floor(Number(value) || 0)),
|
||||
persistAutoRunTimerPlan: async () => ({}),
|
||||
resetState: async () => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
stepStatuses: {},
|
||||
tabRegistry: {},
|
||||
sourceLastUrls: {},
|
||||
};
|
||||
},
|
||||
runAutoSequenceFromStep: async () => {
|
||||
events.runCalls += 1;
|
||||
if (events.runCalls === 1) {
|
||||
throw new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
|
||||
}
|
||||
},
|
||||
runtime,
|
||||
setState: async (updates = {}) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
tabRegistry: updates.tabRegistry ? { ...updates.tabRegistry } : currentState.tabRegistry,
|
||||
sourceLastUrls: updates.sourceLastUrls ? { ...updates.sourceLastUrls } : currentState.sourceLastUrls,
|
||||
};
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfAutoRunSessionStopped: (sessionId) => {
|
||||
if (sessionId && sessionId !== runtime.state.autoRunSessionId) {
|
||||
throw new Error('流程已被用户停止。');
|
||||
}
|
||||
},
|
||||
waitForRunningStepsToFinish: async () => currentState,
|
||||
chrome: {
|
||||
runtime: {
|
||||
sendMessage() {
|
||||
return Promise.resolve();
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await controller.autoRunLoop(2, {
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
});
|
||||
|
||||
assert.equal(events.runCalls, 2, 'user_already_exists failure should skip the current round and continue with the next round');
|
||||
assert.equal(events.broadcasts.some(({ phase }) => phase === 'retrying'), false, 'user_already_exists failure should not enter same-round retrying');
|
||||
assert.equal(events.accountRecords.length, 1, 'user_already_exists should still persist a failed round record');
|
||||
assert.equal(events.accountRecords[0].status, 'failed');
|
||||
assert.match(events.accountRecords[0].reason, /SIGNUP_USER_ALREADY_EXISTS::/);
|
||||
assert.ok(events.logs.some(({ message }) => /继续下一轮/.test(message)));
|
||||
assert.equal(runtime.state.autoRunActive, false);
|
||||
assert.equal(runtime.state.autoRunSessionId, 0);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
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;
|
||||
} 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);
|
||||
}
|
||||
|
||||
const bundle = [
|
||||
extractFunction('isAddPhoneAuthUrl'),
|
||||
extractFunction('isAddPhoneAuthState'),
|
||||
extractFunction('isMail2925ThreadTerminatedError'),
|
||||
extractFunction('isSignupUserAlreadyExistsFailure'),
|
||||
extractFunction('getPostStep6AutoRestartDecision'),
|
||||
extractFunction('runAutoSequenceFromStep'),
|
||||
].join('\n');
|
||||
|
||||
test('auto-run stops step4 restart path when mail2925 ends the current thread', async () => {
|
||||
const api = new Function(`
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const chrome = {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => {},
|
||||
},
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
email: 'demo123456@2925.com',
|
||||
password: 'Secret123!',
|
||||
mailProvider: '2925',
|
||||
stepStatuses: {
|
||||
1: 'pending',
|
||||
2: 'pending',
|
||||
3: 'pending',
|
||||
4: 'pending',
|
||||
5: 'pending',
|
||||
6: 'pending',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
const events = {
|
||||
steps: [],
|
||||
invalidations: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
|
||||
async function ensureAutoEmailReady() {
|
||||
return currentState.email;
|
||||
}
|
||||
|
||||
async function broadcastAutoRunStatus() {}
|
||||
|
||||
async function getState() {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
async function setState(updates) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
function isStopError(error) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isStepDoneStatus(status) {
|
||||
return status === 'completed' || status === 'manual_completed' || status === 'skipped';
|
||||
}
|
||||
|
||||
async function executeStepAndWait(step) {
|
||||
events.steps.push(step);
|
||||
if (step === 4) {
|
||||
throw new Error('MAIL2925_THREAD_TERMINATED::步骤 4:2925 已切换账号并要求结束当前尝试。');
|
||||
}
|
||||
}
|
||||
|
||||
async function getTabId() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function invalidateDownstreamAfterStepRestart(step, options = {}) {
|
||||
events.invalidations.push({ step, options });
|
||||
}
|
||||
|
||||
function getLoginAuthStateLabel(state) {
|
||||
return state || 'unknown';
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
|
||||
async function getLoginAuthStateFromContent() {
|
||||
return { state: 'password_page', url: 'https://auth.openai.com/log-in' };
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
try {
|
||||
await runAutoSequenceFromStep(1, {
|
||||
targetRun: 1,
|
||||
totalRuns: 1,
|
||||
attemptRuns: 1,
|
||||
continued: false,
|
||||
});
|
||||
return { events, currentState, error: null };
|
||||
} catch (error) {
|
||||
return { events, currentState, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
|
||||
assert.match(result.error, /^MAIL2925_THREAD_TERMINATED::/);
|
||||
assert.deepStrictEqual(result.events.invalidations, []);
|
||||
assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]);
|
||||
assert.equal(result.events.logs.some(({ message }) => /回到步骤 1/.test(message)), false);
|
||||
});
|
||||
@@ -55,6 +55,8 @@ function extractFunction(name) {
|
||||
const bundle = [
|
||||
extractFunction('isAddPhoneAuthUrl'),
|
||||
extractFunction('isAddPhoneAuthState'),
|
||||
extractFunction('isMail2925ThreadTerminatedError'),
|
||||
extractFunction('isSignupUserAlreadyExistsFailure'),
|
||||
extractFunction('getPostStep6AutoRestartDecision'),
|
||||
extractFunction('runAutoSequenceFromStep'),
|
||||
].join('\n');
|
||||
@@ -207,3 +209,124 @@ return {
|
||||
assert.equal(currentState.password, 'Secret123!');
|
||||
assert.equal(events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), true);
|
||||
});
|
||||
|
||||
test('auto-run does not restart step 4 current attempt when user_already_exists is detected', async () => {
|
||||
const api = new Function(`
|
||||
const AUTO_STEP_DELAYS = { 1: 0, 2: 0, 3: 0, 4: 0, 5: 0, 6: 0, 7: 0, 8: 0, 9: 0, 10: 0 };
|
||||
const LAST_STEP_ID = 10;
|
||||
const FINAL_OAUTH_CHAIN_START_STEP = 7;
|
||||
const chrome = {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => {},
|
||||
},
|
||||
};
|
||||
|
||||
let currentState = {
|
||||
email: 'existing@example.com',
|
||||
password: 'Secret123!',
|
||||
mailProvider: '163',
|
||||
stepStatuses: {
|
||||
1: 'pending',
|
||||
2: 'pending',
|
||||
3: 'pending',
|
||||
4: 'pending',
|
||||
5: 'pending',
|
||||
6: 'pending',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
const events = {
|
||||
steps: [],
|
||||
invalidations: [],
|
||||
logs: [],
|
||||
};
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
|
||||
async function ensureAutoEmailReady() {
|
||||
return currentState.email;
|
||||
}
|
||||
|
||||
async function broadcastAutoRunStatus() {}
|
||||
|
||||
async function getState() {
|
||||
return currentState;
|
||||
}
|
||||
|
||||
async function setState(updates) {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
stepStatuses: updates.stepStatuses ? { ...updates.stepStatuses } : currentState.stepStatuses,
|
||||
};
|
||||
}
|
||||
|
||||
function isStopError(error) {
|
||||
return (error?.message || String(error || '')) === '流程已被用户停止。';
|
||||
}
|
||||
|
||||
function isStepDoneStatus(status) {
|
||||
return status === 'completed' || status === 'manual_completed' || status === 'skipped';
|
||||
}
|
||||
|
||||
async function executeStepAndWait(step) {
|
||||
events.steps.push(step);
|
||||
if (step === 4) {
|
||||
throw new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
|
||||
}
|
||||
}
|
||||
|
||||
async function getTabId() {
|
||||
return 1;
|
||||
}
|
||||
|
||||
async function invalidateDownstreamAfterStepRestart(step, options = {}) {
|
||||
events.invalidations.push({ step, options });
|
||||
}
|
||||
|
||||
function getLoginAuthStateLabel(state) {
|
||||
return state || 'unknown';
|
||||
}
|
||||
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
|
||||
async function getLoginAuthStateFromContent() {
|
||||
return { state: 'password_page', url: 'https://auth.openai.com/log-in' };
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
try {
|
||||
await runAutoSequenceFromStep(1, {
|
||||
targetRun: 1,
|
||||
totalRuns: 1,
|
||||
attemptRuns: 1,
|
||||
continued: false,
|
||||
});
|
||||
return { events, currentState, error: null };
|
||||
} catch (error) {
|
||||
return { events, currentState, error: error.message };
|
||||
}
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
|
||||
assert.match(result.error, /SIGNUP_USER_ALREADY_EXISTS::/);
|
||||
assert.deepStrictEqual(result.events.invalidations, []);
|
||||
assert.deepStrictEqual(result.events.steps, [1, 2, 3, 4]);
|
||||
assert.equal(result.events.logs.some(({ message }) => /沿用当前邮箱回到步骤 1 重新开始/.test(message)), false);
|
||||
});
|
||||
|
||||
@@ -50,6 +50,7 @@ function extractFunction(name) {
|
||||
|
||||
test('background account history settings are normalized independently from hotmail service mode', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeCodex2ApiUrl'),
|
||||
extractFunction('normalizeHotmailLocalBaseUrl'),
|
||||
extractFunction('normalizeAccountRunHistoryHelperBaseUrl'),
|
||||
extractFunction('normalizeVerificationResendCount'),
|
||||
@@ -60,6 +61,7 @@ test('background account history settings are normalized independently from hotm
|
||||
const DEFAULT_HOTMAIL_LOCAL_BASE_URL = 'http://127.0.0.1:17373';
|
||||
const DEFAULT_ACCOUNT_RUN_HISTORY_HELPER_BASE_URL = DEFAULT_HOTMAIL_LOCAL_BASE_URL;
|
||||
const DEFAULT_HOTMAIL_REMOTE_BASE_URL = '';
|
||||
const DEFAULT_CODEX2API_URL = 'http://localhost:8080/admin/accounts';
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
const DEFAULT_SUB2API_PROXY_NAME = '';
|
||||
const HOTMAIL_SERVICE_MODE_REMOTE = 'remote';
|
||||
@@ -70,7 +72,7 @@ const PERSISTED_SETTING_DEFAULTS = {
|
||||
autoStepDelaySeconds: null,
|
||||
mailProvider: '163',
|
||||
};
|
||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : (value === 'codex2api' ? 'codex2api' : 'cpa'); }
|
||||
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
||||
@@ -118,4 +120,16 @@ return {
|
||||
api.normalizePersistentSettingValue('sub2apiDefaultProxyName', ' proxy-a '),
|
||||
'proxy-a'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('codex2apiUrl', 'localhost:8080/admin'),
|
||||
'http://localhost:8080/admin/accounts'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('codex2apiUrl', 'https://codex-admin.example.com/'),
|
||||
'https://codex-admin.example.com/admin/accounts'
|
||||
);
|
||||
assert.equal(
|
||||
api.normalizePersistentSettingValue('codex2apiAdminKey', ' secret-key '),
|
||||
'secret-key'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,18 +100,42 @@ test('account run history helper upgrades old records, keeps stopped items and s
|
||||
assert.equal(fetchCalled, false);
|
||||
assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: false, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), false);
|
||||
assert.equal(helpers.shouldAppendAccountRunTextFile({ accountRunHistoryTextEnabled: true, accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373' }), true);
|
||||
const stoppedRecord = helpers.buildAccountRunHistoryRecord({ email: 'a@b.com', password: 'x' }, 'stopped', 'stop');
|
||||
const stoppedRecord = helpers.buildAccountRunHistoryRecord(
|
||||
{ email: 'a@b.com', password: 'x' },
|
||||
'step7_stopped',
|
||||
'步骤 7 已被用户停止'
|
||||
);
|
||||
assert.equal(stoppedRecord.recordId, 'a@b.com');
|
||||
assert.equal(stoppedRecord.email, 'a@b.com');
|
||||
assert.equal(stoppedRecord.password, 'x');
|
||||
assert.equal(stoppedRecord.finalStatus, 'stopped');
|
||||
assert.equal(stoppedRecord.retryCount, 0);
|
||||
assert.equal(stoppedRecord.failureLabel, '流程已停止');
|
||||
assert.equal(stoppedRecord.failureDetail, 'stop');
|
||||
assert.equal(stoppedRecord.failedStep, null);
|
||||
assert.equal(stoppedRecord.failureLabel, '步骤 7 停止');
|
||||
assert.equal(stoppedRecord.failureDetail, '步骤 7 已被用户停止');
|
||||
assert.equal(stoppedRecord.failedStep, 7);
|
||||
assert.equal(stoppedRecord.source, 'manual');
|
||||
assert.equal(stoppedRecord.autoRunContext, null);
|
||||
assert.ok(stoppedRecord.finishedAt);
|
||||
|
||||
const genericStoppedRecord = helpers.buildAccountRunHistoryRecord({ email: 'stop@b.com', password: 'y' }, 'stopped', 'stop');
|
||||
assert.equal(genericStoppedRecord.failureLabel, '流程已停止');
|
||||
assert.equal(genericStoppedRecord.failedStep, null);
|
||||
|
||||
const normalizedStoppedRecord = helpers.normalizeAccountRunHistoryRecord({
|
||||
recordId: 'legacy-stop@example.com',
|
||||
email: 'legacy-stop@example.com',
|
||||
password: 'secret',
|
||||
finalStatus: 'stopped',
|
||||
finishedAt: '2026-04-17T00:12:00.000Z',
|
||||
retryCount: 0,
|
||||
failureLabel: '流程已停止',
|
||||
failureDetail: '步骤 7 已被用户停止。',
|
||||
failedStep: 7,
|
||||
source: 'manual',
|
||||
autoRunContext: null,
|
||||
});
|
||||
assert.equal(normalizedStoppedRecord.failureLabel, '步骤 7 停止');
|
||||
assert.equal(normalizedStoppedRecord.failedStep, 7);
|
||||
});
|
||||
|
||||
test('account run history helper clears persisted records and syncs full snapshot payload to local helper', async () => {
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
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;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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('throwIfStopped rethrows an explicit stop error even when stopRequested has been cleared', () => {
|
||||
const api = new Function(`
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
let stopRequested = false;
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
return {
|
||||
run(error) {
|
||||
throwIfStopped(error);
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.throws(
|
||||
() => api.run(new Error('流程已被用户停止。')),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
});
|
||||
|
||||
test('executeStep reuses the active top-level auth chain instead of starting a duplicate step', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
|
||||
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
|
||||
let activeTopLevelAuthChainExecution = null;
|
||||
let stopRequested = false;
|
||||
let releaseStep8 = null;
|
||||
const events = {
|
||||
logs: [],
|
||||
statusCalls: [],
|
||||
registryCalls: [],
|
||||
};
|
||||
const state = {
|
||||
stepStatuses: {},
|
||||
};
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
async function setStepStatus(step, status) {
|
||||
state.stepStatuses[step] = status;
|
||||
events.statusCalls.push({ step, status });
|
||||
}
|
||||
async function humanStepDelay() {}
|
||||
async function getState() {
|
||||
return {
|
||||
flowStartTime: null,
|
||||
stepStatuses: { ...state.stepStatuses },
|
||||
};
|
||||
}
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
async function appendManualAccountRunRecordIfNeeded() {}
|
||||
function isTerminalSecurityBlockedError() {
|
||||
return false;
|
||||
}
|
||||
async function handleCloudflareSecurityBlocked() {}
|
||||
function isBrowserSwitchRequiredError() {
|
||||
return false;
|
||||
}
|
||||
async function handleBrowserSwitchRequired() {}
|
||||
function doesStepUseCompletionSignal() {
|
||||
return false;
|
||||
}
|
||||
function isRetryableContentScriptTransportError() {
|
||||
return false;
|
||||
}
|
||||
const stepRegistry = {
|
||||
async executeStep(step) {
|
||||
events.registryCalls.push(step);
|
||||
if (step === 8) {
|
||||
await new Promise((resolve) => {
|
||||
releaseStep8 = resolve;
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
${extractFunction('isAuthChainStep')}
|
||||
${extractFunction('acquireTopLevelAuthChainExecution')}
|
||||
${extractFunction('executeStep')}
|
||||
|
||||
return {
|
||||
executeStep,
|
||||
releaseStep8() {
|
||||
if (releaseStep8) {
|
||||
releaseStep8();
|
||||
}
|
||||
},
|
||||
snapshot() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const firstRun = api.executeStep(8);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const duplicateRun = api.executeStep(7);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
api.releaseStep8();
|
||||
|
||||
await firstRun;
|
||||
await duplicateRun;
|
||||
|
||||
const events = api.snapshot();
|
||||
assert.deepStrictEqual(events.registryCalls, [8]);
|
||||
assert.deepStrictEqual(events.statusCalls, [
|
||||
{ step: 8, status: 'running' },
|
||||
]);
|
||||
assert.ok(events.logs.some(({ message }) => /复用当前授权链/.test(message)));
|
||||
});
|
||||
|
||||
test('executeStep stops flow when browser-switch-required error is raised', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const BROWSER_SWITCH_REQUIRED_ERROR_PREFIX = 'BROWSER_SWITCH_REQUIRED::';
|
||||
const AUTH_CHAIN_STEP_IDS = new Set([7, 8, 9, 10]);
|
||||
let activeTopLevelAuthChainExecution = null;
|
||||
let stopRequested = false;
|
||||
const events = {
|
||||
logs: [],
|
||||
statusCalls: [],
|
||||
stopRequests: [],
|
||||
appendRecords: [],
|
||||
};
|
||||
|
||||
async function addLog(message, level = 'info') {
|
||||
events.logs.push({ message, level });
|
||||
}
|
||||
async function setStepStatus(step, status) {
|
||||
events.statusCalls.push({ step, status });
|
||||
}
|
||||
async function humanStepDelay() {}
|
||||
async function getState() {
|
||||
return {
|
||||
flowStartTime: null,
|
||||
stepStatuses: {},
|
||||
};
|
||||
}
|
||||
function getErrorMessage(error) {
|
||||
return error?.message || String(error || '');
|
||||
}
|
||||
async function appendManualAccountRunRecordIfNeeded(status, _state, reason) {
|
||||
events.appendRecords.push({ status, reason });
|
||||
}
|
||||
function isTerminalSecurityBlockedError() {
|
||||
return false;
|
||||
}
|
||||
async function handleCloudflareSecurityBlocked() {}
|
||||
async function requestStop(options = {}) {
|
||||
events.stopRequests.push(options);
|
||||
}
|
||||
function doesStepUseCompletionSignal() {
|
||||
return false;
|
||||
}
|
||||
function isRetryableContentScriptTransportError() {
|
||||
return false;
|
||||
}
|
||||
const stepRegistry = {
|
||||
async executeStep() {
|
||||
throw new Error('BROWSER_SWITCH_REQUIRED::请更换浏览器进行注册登录。');
|
||||
},
|
||||
};
|
||||
|
||||
${extractFunction('isStopError')}
|
||||
${extractFunction('throwIfStopped')}
|
||||
${extractFunction('isAuthChainStep')}
|
||||
${extractFunction('acquireTopLevelAuthChainExecution')}
|
||||
${extractFunction('isBrowserSwitchRequiredError')}
|
||||
${extractFunction('getBrowserSwitchRequiredMessage')}
|
||||
${extractFunction('handleBrowserSwitchRequired')}
|
||||
${extractFunction('executeStep')}
|
||||
|
||||
return {
|
||||
executeStep,
|
||||
snapshot() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.executeStep(10),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
|
||||
const events = api.snapshot();
|
||||
assert.deepStrictEqual(events.stopRequests, [
|
||||
{ logMessage: '请更换浏览器进行注册登录。' },
|
||||
]);
|
||||
assert.deepStrictEqual(events.statusCalls, [
|
||||
{ step: 10, status: 'running' },
|
||||
]);
|
||||
assert.equal(
|
||||
events.logs.some(({ message }) => /步骤 10 失败/.test(message)),
|
||||
false,
|
||||
'browser-switch-required error should stop the flow before generic failed logging'
|
||||
);
|
||||
});
|
||||
|
||||
test('oauth timeout budget ignores stale deadlines from an old oauth url', async () => {
|
||||
const api = new Function(`
|
||||
const LOG_PREFIX = '[test]';
|
||||
const OAUTH_FLOW_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
${extractFunction('normalizeOAuthFlowDeadlineAt')}
|
||||
${extractFunction('normalizeOAuthFlowSourceUrl')}
|
||||
${extractFunction('getOAuthFlowRemainingMs')}
|
||||
${extractFunction('getOAuthFlowStepTimeoutMs')}
|
||||
return {
|
||||
getOAuthFlowStepTimeoutMs,
|
||||
};
|
||||
`)();
|
||||
|
||||
const timeoutMs = await api.getOAuthFlowStepTimeoutMs(15000, {
|
||||
step: 8,
|
||||
actionLabel: '登录验证码流程',
|
||||
state: {
|
||||
oauthUrl: 'https://oauth.example/current',
|
||||
oauthFlowDeadlineAt: Date.now() + 1200,
|
||||
oauthFlowDeadlineSourceUrl: 'https://oauth.example/old',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(timeoutMs, 15000);
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
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;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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('cloudflare temp email settings normalize and expose the random-subdomain toggle', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeCloudflareTempEmailReceiveMailbox'),
|
||||
extractFunction('getCloudflareTempEmailConfig'),
|
||||
extractFunction('normalizePersistentSettingValue'),
|
||||
extractFunction('buildPersistentSettingsPayload'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
const PERSISTED_SETTING_DEFAULTS = {
|
||||
panelMode: 'cpa',
|
||||
autoStepDelaySeconds: null,
|
||||
verificationResendCount: DEFAULT_VERIFICATION_RESEND_COUNT,
|
||||
mailProvider: '163',
|
||||
mail2925Mode: 'provide',
|
||||
emailGenerator: 'duck',
|
||||
autoDeleteUsedIcloudAlias: false,
|
||||
accountRunHistoryTextEnabled: false,
|
||||
cloudflareTempEmailUseRandomSubdomain: false,
|
||||
cloudflareTempEmailDomain: '',
|
||||
cloudflareTempEmailDomains: [],
|
||||
};
|
||||
const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);
|
||||
function normalizePanelMode(value) { return value === 'sub2api' ? 'sub2api' : 'cpa'; }
|
||||
function normalizeLocalCpaStep9Mode(value) { return value === 'bypass' ? 'bypass' : 'submit'; }
|
||||
function normalizeAutoRunFallbackThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoRunDelayMinutes(value) { return Number(value) || 30; }
|
||||
function normalizeAutoStepDelaySeconds(value, fallback = null) { return value == null || value === '' ? fallback : Number(value); }
|
||||
function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
|
||||
function normalizeMailProvider(value) { return String(value || '').trim().toLowerCase() || '163'; }
|
||||
function normalizeMail2925Mode(value) { return String(value || '').trim().toLowerCase() === 'receive' ? 'receive' : 'provide'; }
|
||||
function normalizeEmailGenerator(value) { return String(value || '').trim().toLowerCase() || 'duck'; }
|
||||
function normalizeIcloudHost(value) { const normalized = String(value || '').trim().toLowerCase(); return normalized === 'icloud.com' || normalized === 'icloud.com.cn' ? normalized : ''; }
|
||||
function normalizeAccountRunHistoryHelperBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeHotmailServiceMode(value) { return String(value || '').trim().toLowerCase() === 'remote' ? 'remote' : 'local'; }
|
||||
function normalizeHotmailRemoteBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeHotmailLocalBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareDomain(value) { return String(value || '').trim().toLowerCase(); }
|
||||
function normalizeCloudflareDomains(value) { return Array.isArray(value) ? value.map((item) => String(item || '').trim().toLowerCase()).filter(Boolean) : []; }
|
||||
function normalizeCloudflareTempEmailBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailAddress(value = '') { return String(value || '').trim().toLowerCase(); }
|
||||
function normalizeCloudflareTempEmailDomain(value) { return String(value || '').trim().toLowerCase(); }
|
||||
function normalizeCloudflareTempEmailDomains(value) {
|
||||
const seen = new Set();
|
||||
const domains = [];
|
||||
for (const item of Array.isArray(value) ? value : []) {
|
||||
const normalized = normalizeCloudflareTempEmailDomain(item);
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
domains.push(normalized);
|
||||
}
|
||||
return domains;
|
||||
}
|
||||
function normalizeHotmailAccounts(value) { return Array.isArray(value) ? value : []; }
|
||||
function normalizeMail2925Accounts(value) { return Array.isArray(value) ? value : []; }
|
||||
function resolveLegacyAutoStepDelaySeconds() { return undefined; }
|
||||
${bundle}
|
||||
return {
|
||||
buildPersistentSettingsPayload,
|
||||
getCloudflareTempEmailConfig,
|
||||
normalizePersistentSettingValue,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.normalizePersistentSettingValue('cloudflareTempEmailUseRandomSubdomain', 1), true);
|
||||
|
||||
const payload = api.buildPersistentSettingsPayload({
|
||||
cloudflareTempEmailUseRandomSubdomain: true,
|
||||
cloudflareTempEmailDomain: 'mail.example.com',
|
||||
cloudflareTempEmailDomains: ['mail.example.com', 'alt.example.com'],
|
||||
});
|
||||
assert.equal(payload.cloudflareTempEmailUseRandomSubdomain, true);
|
||||
assert.equal(payload.cloudflareTempEmailDomain, 'mail.example.com');
|
||||
assert.deepEqual(payload.cloudflareTempEmailDomains, ['mail.example.com', 'alt.example.com']);
|
||||
|
||||
const config = api.getCloudflareTempEmailConfig({
|
||||
cloudflareTempEmailBaseUrl: 'https://temp.example.com',
|
||||
cloudflareTempEmailAdminAuth: 'admin-secret',
|
||||
cloudflareTempEmailCustomAuth: 'custom-secret',
|
||||
cloudflareTempEmailReceiveMailbox: 'Forward@Example.com',
|
||||
cloudflareTempEmailUseRandomSubdomain: true,
|
||||
cloudflareTempEmailDomain: 'mail.example.com',
|
||||
cloudflareTempEmailDomains: ['mail.example.com'],
|
||||
});
|
||||
assert.deepEqual(config, {
|
||||
baseUrl: 'https://temp.example.com',
|
||||
adminAuth: 'admin-secret',
|
||||
customAuth: 'custom-secret',
|
||||
receiveMailbox: 'forward@example.com',
|
||||
useRandomSubdomain: true,
|
||||
domain: 'mail.example.com',
|
||||
domains: ['mail.example.com'],
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,622 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const backgroundSource = fs.readFileSync('background.js', 'utf8');
|
||||
|
||||
function extractFunction(source, 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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function createMockResponse(ok, status, payload) {
|
||||
return {
|
||||
ok,
|
||||
status,
|
||||
async json() {
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('background imports contribution oauth module and keeps contribution runtime out of persisted settings', () => {
|
||||
const persistedStart = backgroundSource.indexOf('const PERSISTED_SETTING_DEFAULTS = {');
|
||||
const persistedEnd = backgroundSource.indexOf('const PERSISTED_SETTING_KEYS = Object.keys(PERSISTED_SETTING_DEFAULTS);');
|
||||
const defaultStateStart = backgroundSource.indexOf('const DEFAULT_STATE = {');
|
||||
const defaultStateEnd = backgroundSource.indexOf('async function getState()');
|
||||
|
||||
const persistedBlock = backgroundSource.slice(persistedStart, persistedEnd);
|
||||
const defaultStateBlock = backgroundSource.slice(defaultStateStart, defaultStateEnd);
|
||||
|
||||
assert.match(backgroundSource, /background\/contribution-oauth\.js/);
|
||||
assert.doesNotMatch(persistedBlock, /contributionSessionId|contributionAuthUrl|contributionCallbackUrl|contributionStatus/);
|
||||
assert.match(defaultStateBlock, /contributionMode:\s*false|CONTRIBUTION_RUNTIME_DEFAULTS/);
|
||||
});
|
||||
|
||||
test('contribution oauth module exposes a factory', () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async () => createMockResponse(true, 200, { ok: true })
|
||||
);
|
||||
|
||||
assert.equal(typeof api?.createContributionOAuthManager, 'function');
|
||||
assert.equal(Array.isArray(api?.RUNTIME_KEYS), true);
|
||||
});
|
||||
|
||||
test('buildContributionModeState preserves active contribution runtime while forcing CPA mode', () => {
|
||||
const bundle = extractFunction(backgroundSource, 'buildContributionModeState');
|
||||
|
||||
const api = new Function(`
|
||||
const DEFAULT_STATE = { panelMode: 'cpa' };
|
||||
const CONTRIBUTION_RUNTIME_DEFAULTS = {
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
};
|
||||
const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);
|
||||
${bundle}
|
||||
return { buildContributionModeState };
|
||||
`)();
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(true, {
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionStatus: 'waiting',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
}),
|
||||
{
|
||||
contributionMode: true,
|
||||
contributionModeExpected: true,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: 'waiting',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'cpa',
|
||||
customPassword: '',
|
||||
accountRunHistoryTextEnabled: false,
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
api.buildContributionModeState(false, {
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}, {
|
||||
contributionSessionId: 'session-001',
|
||||
contributionAuthUrl: 'https://auth.example.com',
|
||||
contributionStatus: 'waiting',
|
||||
}),
|
||||
{
|
||||
contributionMode: false,
|
||||
contributionModeExpected: false,
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionLastPollAt: 0,
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
contributionAuthOpenedAt: 0,
|
||||
contributionAuthTabId: 0,
|
||||
panelMode: 'sub2api',
|
||||
customPassword: 'Secret123!',
|
||||
accountRunHistoryTextEnabled: true,
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('resetState preserves contribution runtime across reset', () => {
|
||||
assert.match(backgroundSource, /CONTRIBUTION_RUNTIME_KEYS/);
|
||||
assert.match(backgroundSource, /const contributionModeState = buildContributionModeState/);
|
||||
assert.match(backgroundSource, /\.\.\.contributionModeState/);
|
||||
});
|
||||
|
||||
test('message router handles contribution mode, start flow, and status polling messages', 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({
|
||||
ensureManualInteractionAllowed: async () => ({
|
||||
stepStatuses: { 1: 'pending', 2: 'completed' },
|
||||
contributionMode: true,
|
||||
}),
|
||||
pollContributionStatus: async (options) => {
|
||||
calls.push({ type: 'poll', options });
|
||||
return { contributionStatus: 'waiting' };
|
||||
},
|
||||
setContributionMode: async (enabled) => {
|
||||
calls.push({ type: 'toggle', enabled });
|
||||
return {
|
||||
contributionMode: Boolean(enabled),
|
||||
panelMode: 'cpa',
|
||||
};
|
||||
},
|
||||
startContributionFlow: async (options) => {
|
||||
calls.push({ type: 'start', options });
|
||||
return {
|
||||
contributionMode: true,
|
||||
contributionSessionId: 'session-001',
|
||||
contributionStatus: 'started',
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const enableResponse = await router.handleMessage({
|
||||
type: 'SET_CONTRIBUTION_MODE',
|
||||
payload: { enabled: true },
|
||||
});
|
||||
const startResponse = await router.handleMessage({
|
||||
type: 'START_CONTRIBUTION_FLOW',
|
||||
payload: { nickname: '阿青', qq: '123456' },
|
||||
});
|
||||
const pollResponse = await router.handleMessage({
|
||||
type: 'POLL_CONTRIBUTION_STATUS',
|
||||
payload: { reason: 'test_poll' },
|
||||
});
|
||||
|
||||
assert.equal(enableResponse.ok, true);
|
||||
assert.equal(startResponse.ok, true);
|
||||
assert.equal(pollResponse.ok, true);
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'toggle', enabled: true },
|
||||
{ type: 'start', options: { nickname: '阿青', qq: '123456' } },
|
||||
{ type: 'poll', options: { reason: 'test_poll' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('message router re-syncs contribution mode before AUTO_RUN when sidepanel payload marks contributionMode=true', 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 () => ({
|
||||
contributionMode: false,
|
||||
stepStatuses: {},
|
||||
}),
|
||||
normalizeRunCount: (value) => Number(value) || 1,
|
||||
setContributionMode: async (enabled) => {
|
||||
calls.push({ type: 'toggle', enabled });
|
||||
return { contributionMode: true };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
calls.push({ type: 'setState', updates });
|
||||
},
|
||||
startAutoRunLoop: (totalRuns, options) => {
|
||||
calls.push({ type: 'startAutoRunLoop', totalRuns, options });
|
||||
},
|
||||
});
|
||||
|
||||
const response = await router.handleMessage({
|
||||
type: 'AUTO_RUN',
|
||||
payload: {
|
||||
totalRuns: 2,
|
||||
autoRunSkipFailures: true,
|
||||
mode: 'restart',
|
||||
contributionMode: true,
|
||||
contributionNickname: '阿青',
|
||||
contributionQq: '123456',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(response.ok, true);
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'toggle', enabled: true },
|
||||
{ type: 'setState', updates: { contributionNickname: '阿青', contributionQq: '123456' } },
|
||||
{ type: 'setState', updates: { autoRunSkipFailures: true } },
|
||||
{ type: 'startAutoRunLoop', totalRuns: 2, options: { autoRunSkipFailures: true, mode: 'restart' } },
|
||||
]);
|
||||
});
|
||||
|
||||
test('account run history snapshot sync is disabled in contribution mode', () => {
|
||||
const source = fs.readFileSync('background/account-run-history.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundAccountRunHistory;`)(globalScope);
|
||||
|
||||
const helpers = api.createAccountRunHistoryHelpers({
|
||||
addLog: async () => {},
|
||||
buildLocalHelperEndpoint: (baseUrl, path) => `${baseUrl}${path}`,
|
||||
chrome: {
|
||||
storage: {
|
||||
local: {
|
||||
get: async () => ({}),
|
||||
set: async () => {},
|
||||
},
|
||||
},
|
||||
},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getState: async () => ({}),
|
||||
normalizeAccountRunHistoryHelperBaseUrl: (value) => String(value || '').trim(),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
helpers.shouldSyncAccountRunHistorySnapshot({
|
||||
contributionMode: true,
|
||||
accountRunHistoryTextEnabled: true,
|
||||
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
|
||||
}),
|
||||
false
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
helpers.shouldSyncAccountRunHistorySnapshot({
|
||||
contributionMode: false,
|
||||
accountRunHistoryTextEnabled: true,
|
||||
accountRunHistoryHelperBaseUrl: 'http://127.0.0.1:17373',
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('contribution oauth manager starts session, opens auth url, submits callback, and continues polling final status', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const fetchCalls = [];
|
||||
const tabCalls = [];
|
||||
const closeCallbackCalls = [];
|
||||
let statusPollCount = 0;
|
||||
let currentState = {
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
};
|
||||
const broadcasts = [];
|
||||
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async (url, options = {}) => {
|
||||
fetchCalls.push({ url, options });
|
||||
if (String(url).endsWith('/start')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
state: 'oauth-state-001',
|
||||
auth_url: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
message: '登录地址已生成',
|
||||
});
|
||||
}
|
||||
if (String(url).includes('/status?')) {
|
||||
statusPollCount += 1;
|
||||
if (statusPollCount === 1) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'waiting',
|
||||
message: '等待提交回调。',
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'processing',
|
||||
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||
});
|
||||
}
|
||||
if (String(url).endsWith('/submit-callback')) {
|
||||
return createMockResponse(true, 200, {
|
||||
ok: true,
|
||||
session_id: 'session-001',
|
||||
status: 'processing',
|
||||
message: '回调地址已提交给 CPA,正在等待结果确认。',
|
||||
});
|
||||
}
|
||||
return createMockResponse(true, 200, { ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate(updates) {
|
||||
broadcasts.push(updates);
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
chrome: {
|
||||
tabs: {
|
||||
async create(payload) {
|
||||
tabCalls.push(payload);
|
||||
return { id: 88, url: payload.url };
|
||||
},
|
||||
async update() {
|
||||
return null;
|
||||
},
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
closeLocalhostCallbackTabs: async (callbackUrl) => {
|
||||
closeCallbackCalls.push(callbackUrl);
|
||||
},
|
||||
getState: async () => currentState,
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
});
|
||||
|
||||
const startedState = await manager.startContributionFlow();
|
||||
assert.equal(startedState.contributionSessionId, 'session-001');
|
||||
assert.equal(startedState.contributionAuthState, 'oauth-state-001');
|
||||
assert.equal(startedState.contributionStatus, 'waiting');
|
||||
assert.equal(startedState.contributionAuthTabId, 88);
|
||||
assert.equal(tabCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/start$/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"nickname":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"qq":""/);
|
||||
assert.match(String(fetchCalls[0].options.body || ''), /"email":"user@example\.com"/);
|
||||
assert.match(fetchCalls[1].url, /\/status\?/);
|
||||
|
||||
const callbackState = await manager.handleCapturedCallback(
|
||||
'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001',
|
||||
{ source: 'test' }
|
||||
);
|
||||
|
||||
assert.equal(callbackState.contributionCallbackUrl, 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001');
|
||||
assert.equal(callbackState.contributionCallbackStatus, 'submitted');
|
||||
assert.equal(callbackState.contributionStatus, 'processing');
|
||||
assert.equal(closeCallbackCalls[0], 'http://localhost:1455/auth/callback?code=abc123&state=oauth-state-001');
|
||||
assert.ok(fetchCalls.some((call) => String(call.url).endsWith('/submit-callback')));
|
||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'captured'));
|
||||
assert.ok(broadcasts.some((item) => item.contributionCallbackStatus === 'submitted'));
|
||||
});
|
||||
|
||||
test('contribution oauth manager accepts localhost callback urls that contain error and state', async () => {
|
||||
const source = fs.readFileSync('background/contribution-oauth.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', 'fetch', `${source}; return self.MultiPageBackgroundContributionOAuth;`)(
|
||||
globalScope,
|
||||
async () => createMockResponse(true, 200, { ok: true })
|
||||
);
|
||||
|
||||
const manager = api.createContributionOAuthManager({
|
||||
chrome: {
|
||||
tabs: {
|
||||
onUpdated: { addListener() {} },
|
||||
},
|
||||
webNavigation: {
|
||||
onCommitted: { addListener() {} },
|
||||
onHistoryStateUpdated: { addListener() {} },
|
||||
},
|
||||
},
|
||||
getState: async () => ({}),
|
||||
setState: async () => ({}),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
manager.isContributionCallbackUrl(
|
||||
'http://localhost:1455/auth/callback?error=access_denied&state=oauth-state-001',
|
||||
{ contributionAuthState: 'oauth-state-001' }
|
||||
),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 uses contribution oauth session instead of panel bridge in contribution mode', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { refreshOAuthUrlBeforeStep6 };
|
||||
`)();
|
||||
|
||||
globalThis.addLog = async (message) => {
|
||||
calls.push({ type: 'log', message });
|
||||
};
|
||||
globalThis.contributionOAuthManager = {
|
||||
async startContributionFlow(options) {
|
||||
calls.push({ type: 'contribution', options });
|
||||
return {
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.handleStepData = async (step, payload) => {
|
||||
calls.push({ type: 'step', step, payload });
|
||||
};
|
||||
globalThis.getPanelModeLabel = () => 'CPA';
|
||||
globalThis.requestOAuthUrlFromPanel = async () => {
|
||||
calls.push({ type: 'panel' });
|
||||
return { oauthUrl: 'https://panel.example.com/oauth' };
|
||||
};
|
||||
globalThis.LOG_PREFIX = '[test]';
|
||||
|
||||
const oauthUrl = await api.refreshOAuthUrlBeforeStep6({
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
assert.equal(oauthUrl, 'https://auth.example.com/oauth?state=oauth-state-001');
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'log', message: '步骤 7:contributionMode=true,走公开贡献接口,正在申请 OAuth 登录地址...' },
|
||||
{
|
||||
type: 'contribution',
|
||||
options: {
|
||||
nickname: '',
|
||||
openAuthTab: false,
|
||||
stateOverride: {
|
||||
contributionMode: true,
|
||||
email: 'user@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'step',
|
||||
step: 1,
|
||||
payload: {
|
||||
oauthUrl: 'https://auth.example.com/oauth?state=oauth-state-001',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
delete globalThis.addLog;
|
||||
delete globalThis.contributionOAuthManager;
|
||||
delete globalThis.handleStepData;
|
||||
delete globalThis.getPanelModeLabel;
|
||||
delete globalThis.requestOAuthUrlFromPanel;
|
||||
delete globalThis.LOG_PREFIX;
|
||||
});
|
||||
|
||||
test('refreshOAuthUrlBeforeStep6 logs the normal CPA/SUB2API/Codex2API path explicitly when contributionMode=false', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'refreshOAuthUrlBeforeStep6');
|
||||
const calls = [];
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { refreshOAuthUrlBeforeStep6 };
|
||||
`)();
|
||||
|
||||
globalThis.addLog = async (message) => {
|
||||
calls.push({ type: 'log', message });
|
||||
};
|
||||
globalThis.contributionOAuthManager = {
|
||||
async startContributionFlow() {
|
||||
calls.push({ type: 'contribution' });
|
||||
return {
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=unexpected',
|
||||
};
|
||||
},
|
||||
};
|
||||
globalThis.handleStepData = async (step, payload) => {
|
||||
calls.push({ type: 'step', step, payload });
|
||||
};
|
||||
globalThis.getPanelModeLabel = () => 'SUB2API';
|
||||
globalThis.requestOAuthUrlFromPanel = async () => {
|
||||
calls.push({ type: 'panel' });
|
||||
return { oauthUrl: 'https://panel.example.com/oauth' };
|
||||
};
|
||||
globalThis.LOG_PREFIX = '[test]';
|
||||
|
||||
const oauthUrl = await api.refreshOAuthUrlBeforeStep6({
|
||||
contributionMode: false,
|
||||
panelMode: 'sub2api',
|
||||
email: 'user@example.com',
|
||||
});
|
||||
|
||||
assert.equal(oauthUrl, 'https://panel.example.com/oauth');
|
||||
assert.deepStrictEqual(calls, [
|
||||
{ type: 'log', message: '步骤 7:contributionMode=false,走普通 CPA / SUB2API / Codex2API 链路(当前面板:SUB2API),正在刷新 OAuth 登录地址...' },
|
||||
{ type: 'panel' },
|
||||
{
|
||||
type: 'step',
|
||||
step: 1,
|
||||
payload: {
|
||||
oauthUrl: 'https://panel.example.com/oauth',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
delete globalThis.addLog;
|
||||
delete globalThis.contributionOAuthManager;
|
||||
delete globalThis.handleStepData;
|
||||
delete globalThis.getPanelModeLabel;
|
||||
delete globalThis.requestOAuthUrlFromPanel;
|
||||
delete globalThis.LOG_PREFIX;
|
||||
});
|
||||
|
||||
test('executeStep10 blocks silent fallback when contributionModeExpected=true but contributionMode=false', async () => {
|
||||
const bundle = extractFunction(backgroundSource, 'executeStep10');
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { executeStep10 };
|
||||
`)();
|
||||
|
||||
globalThis.executeContributionStep10 = async () => ({ ok: true });
|
||||
globalThis.step10Executor = {
|
||||
async executeStep10() {
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
() => api.executeStep10({
|
||||
contributionModeExpected: true,
|
||||
contributionMode: false,
|
||||
}),
|
||||
/步骤 10:当前自动流程预期使用贡献模式/
|
||||
);
|
||||
|
||||
delete globalThis.executeContributionStep10;
|
||||
delete globalThis.step10Executor;
|
||||
});
|
||||
@@ -2,16 +2,232 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function loadGeneratedEmailHelpersApi() {
|
||||
const source = fs.readFileSync('background/generated-email-helpers.js', 'utf8');
|
||||
const globalScope = {};
|
||||
return new Function('self', `${source}; return self.MultiPageGeneratedEmailHelpers;`)(globalScope);
|
||||
}
|
||||
|
||||
test('background imports generated email helper module', () => {
|
||||
const source = fs.readFileSync('background.js', 'utf8');
|
||||
assert.match(source, /importScripts\([\s\S]*'background\/generated-email-helpers\.js'/);
|
||||
});
|
||||
|
||||
test('generated email helper module exposes a factory', () => {
|
||||
const source = fs.readFileSync('background/generated-email-helpers.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
const api = new Function('self', `${source}; return self.MultiPageGeneratedEmailHelpers;`)(globalScope);
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
|
||||
assert.equal(typeof api?.createGeneratedEmailHelpers, 'function');
|
||||
});
|
||||
|
||||
test('generated email helper falls back to normal generator when 2925 is in receive mode', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const events = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: () => {
|
||||
throw new Error('should not build alias in receive mode');
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({}),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async () => ({ ok: true, text: async () => '{}' }),
|
||||
fetchIcloudHideMyEmail: async () => {
|
||||
throw new Error('should not use icloud generator');
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: () => '',
|
||||
getCloudflareTempEmailConfig: () => ({ baseUrl: '', adminAuth: '', domain: '' }),
|
||||
getState: async () => ({
|
||||
mailProvider: '2925',
|
||||
mail2925Mode: 'receive',
|
||||
emailGenerator: 'duck',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate 2925 account in receive mode');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: () => '',
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: () => '',
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: (_provider, mail2925Mode) => mail2925Mode === 'provide',
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async (_source, message) => {
|
||||
events.push(message.type);
|
||||
return { email: 'duck@example.com', generated: true };
|
||||
},
|
||||
setEmailState: async (email) => {
|
||||
events.push(['email', email]);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
mailProvider: '2925',
|
||||
mail2925Mode: 'receive',
|
||||
emailGenerator: 'duck',
|
||||
}, {
|
||||
mailProvider: '2925',
|
||||
mail2925Mode: 'receive',
|
||||
generator: 'duck',
|
||||
});
|
||||
|
||||
assert.equal(email, 'duck@example.com');
|
||||
assert.deepStrictEqual(events, [
|
||||
'FETCH_DUCK_EMAIL',
|
||||
['email', 'duck@example.com'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('generated email helper uses the regular temp email domain when random subdomain mode is disabled', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const requests = [];
|
||||
const savedEmails = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: () => {
|
||||
throw new Error('should not build managed alias');
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({ 'x-admin-auth': 'admin-secret' }),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async (url, options = {}) => {
|
||||
requests.push({
|
||||
url,
|
||||
method: options.method,
|
||||
body: options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ address: 'user@mail.example.com' }),
|
||||
};
|
||||
},
|
||||
fetchIcloudHideMyEmail: async () => {
|
||||
throw new Error('should not use icloud generator');
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: (payload) => payload.address,
|
||||
getCloudflareTempEmailConfig: () => ({
|
||||
baseUrl: 'https://temp.example.com',
|
||||
adminAuth: 'admin-secret',
|
||||
customAuth: '',
|
||||
useRandomSubdomain: false,
|
||||
domain: 'mail.example.com',
|
||||
}),
|
||||
getState: async () => ({
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate mail2925 account');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: (baseUrl, path) => `${baseUrl}${path}`,
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: (value) => String(value || '').trim().toLowerCase(),
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: () => false,
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use duck generator');
|
||||
},
|
||||
setEmailState: async (email) => {
|
||||
savedEmails.push(email);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}, {
|
||||
generator: 'cloudflare-temp-email',
|
||||
});
|
||||
|
||||
assert.equal(email, 'user@mail.example.com');
|
||||
assert.deepEqual(savedEmails, ['user@mail.example.com']);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].url, 'https://temp.example.com/admin/new_address');
|
||||
assert.equal(requests[0].method, 'POST');
|
||||
assert.deepEqual(requests[0].body, {
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: false,
|
||||
name: requests[0].body.name,
|
||||
domain: 'mail.example.com',
|
||||
});
|
||||
assert.match(requests[0].body.name, /^[a-z0-9]+$/);
|
||||
});
|
||||
|
||||
test('generated email helper requests random subdomain creation while preserving the returned address', async () => {
|
||||
const api = loadGeneratedEmailHelpersApi();
|
||||
const requests = [];
|
||||
const savedEmails = [];
|
||||
|
||||
const helpers = api.createGeneratedEmailHelpers({
|
||||
addLog: async () => {},
|
||||
buildGeneratedAliasEmail: () => {
|
||||
throw new Error('should not build managed alias');
|
||||
},
|
||||
buildCloudflareTempEmailHeaders: () => ({ 'x-admin-auth': 'admin-secret' }),
|
||||
CLOUDFLARE_TEMP_EMAIL_GENERATOR: 'cloudflare-temp-email',
|
||||
DUCK_AUTOFILL_URL: 'https://duckduckgo.com/email',
|
||||
fetch: async (url, options = {}) => {
|
||||
requests.push({
|
||||
url,
|
||||
method: options.method,
|
||||
body: options.body ? JSON.parse(options.body) : null,
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
text: async () => JSON.stringify({ address: 'user@a1b2c3d4.example.com' }),
|
||||
};
|
||||
},
|
||||
fetchIcloudHideMyEmail: async () => {
|
||||
throw new Error('should not use icloud generator');
|
||||
},
|
||||
getCloudflareTempEmailAddressFromResponse: (payload) => payload.address,
|
||||
getCloudflareTempEmailConfig: () => ({
|
||||
baseUrl: 'https://temp.example.com',
|
||||
adminAuth: 'admin-secret',
|
||||
customAuth: '',
|
||||
useRandomSubdomain: true,
|
||||
domain: 'mail.example.com',
|
||||
}),
|
||||
getState: async () => ({
|
||||
mailProvider: '163',
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
throw new Error('should not allocate mail2925 account');
|
||||
},
|
||||
joinCloudflareTempEmailUrl: (baseUrl, path) => `${baseUrl}${path}`,
|
||||
normalizeCloudflareDomain: () => '',
|
||||
normalizeCloudflareTempEmailAddress: (value) => String(value || '').trim().toLowerCase(),
|
||||
normalizeEmailGenerator: (value) => String(value || '').trim().toLowerCase(),
|
||||
isGeneratedAliasProvider: () => false,
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScript: async () => {
|
||||
throw new Error('should not use duck generator');
|
||||
},
|
||||
setEmailState: async (email) => {
|
||||
savedEmails.push(email);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
const email = await helpers.fetchGeneratedEmail({
|
||||
emailGenerator: 'cloudflare-temp-email',
|
||||
}, {
|
||||
generator: 'cloudflare-temp-email',
|
||||
localPart: 'user',
|
||||
});
|
||||
|
||||
assert.equal(email, 'user@a1b2c3d4.example.com');
|
||||
assert.deepEqual(savedEmails, ['user@a1b2c3d4.example.com']);
|
||||
assert.equal(requests.length, 1);
|
||||
assert.equal(requests[0].url, 'https://temp.example.com/admin/new_address');
|
||||
assert.equal(requests[0].method, 'POST');
|
||||
assert.deepEqual(requests[0].body, {
|
||||
enablePrefix: true,
|
||||
enableRandomSubdomain: true,
|
||||
name: 'user',
|
||||
domain: 'mail.example.com',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -143,3 +143,32 @@ return { getMailConfig };
|
||||
navigateOnReuse: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('getMailConfig keeps provider metadata for 2925 mailboxes', () => {
|
||||
const bundle = extractFunction('getMailConfig');
|
||||
const api = new Function(`
|
||||
const ICLOUD_PROVIDER = 'icloud';
|
||||
const GMAIL_PROVIDER = 'gmail';
|
||||
const HOTMAIL_PROVIDER = 'hotmail-api';
|
||||
const LUCKMAIL_PROVIDER = 'luckmail-api';
|
||||
const CLOUDFLARE_TEMP_EMAIL_PROVIDER = 'cloudflare-temp-email';
|
||||
function normalizeIcloudHost(value = '') { return String(value || '').trim().toLowerCase(); }
|
||||
function normalizeInbucketOrigin(value) { return String(value || '').trim(); }
|
||||
function getConfiguredIcloudHostPreference() { return ''; }
|
||||
function getIcloudLoginUrlForHost(host) { return host; }
|
||||
function getIcloudMailUrlForHost(host) { return host; }
|
||||
${bundle}
|
||||
return { getMailConfig };
|
||||
`)();
|
||||
|
||||
assert.deepEqual(api.getMailConfig({
|
||||
mailProvider: '2925',
|
||||
}), {
|
||||
provider: '2925',
|
||||
source: 'mail-2925',
|
||||
url: 'https://2925.com/#/mailList',
|
||||
label: '2925 邮箱',
|
||||
inject: ['content/utils.js', 'content/mail-2925.js'],
|
||||
injectSource: 'mail-2925',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -406,7 +406,10 @@ return {
|
||||
});
|
||||
|
||||
test('resetState preserves LuckMail session config, used map, and preserve tag cache while clearing runtime purchase state', async () => {
|
||||
const bundle = extractFunction('resetState');
|
||||
const bundle = [
|
||||
extractFunction('buildContributionModeState'),
|
||||
extractFunction('resetState'),
|
||||
].join('\n');
|
||||
|
||||
const factory = new Function([
|
||||
'let cleared = false;',
|
||||
@@ -418,6 +421,7 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
|
||||
" luckmailBaseUrl: 'https://mails.luckyous.com',",
|
||||
" luckmailEmailType: 'ms_graph',",
|
||||
" luckmailDomain: '',",
|
||||
" panelMode: 'cpa',",
|
||||
' luckmailUsedPurchases: {},',
|
||||
' luckmailPreserveTagId: 0,',
|
||||
" luckmailPreserveTagName: '保留',",
|
||||
@@ -425,6 +429,21 @@ test('resetState preserves LuckMail session config, used map, and preserve tag c
|
||||
" currentLuckmailMailCursor: { messageId: 'stale' },",
|
||||
' email: null,',
|
||||
'};',
|
||||
'const CONTRIBUTION_RUNTIME_DEFAULTS = {',
|
||||
' contributionMode: false,',
|
||||
" contributionSessionId: '',",
|
||||
" contributionAuthUrl: '',",
|
||||
" contributionAuthState: '',",
|
||||
" contributionCallbackUrl: '',",
|
||||
" contributionStatus: '',",
|
||||
" contributionStatusMessage: '',",
|
||||
' contributionLastPollAt: 0,',
|
||||
" contributionCallbackStatus: 'idle',",
|
||||
" contributionCallbackMessage: '',",
|
||||
' contributionAuthOpenedAt: 0,',
|
||||
' contributionAuthTabId: 0,',
|
||||
'};',
|
||||
'const CONTRIBUTION_RUNTIME_KEYS = Object.keys(CONTRIBUTION_RUNTIME_DEFAULTS);',
|
||||
'function normalizeLuckmailBaseUrl(value) {',
|
||||
" const normalized = String(value || '').trim() || 'https://mails.luckyous.com';",
|
||||
" return normalized.replace(/\\/$/, '');",
|
||||
|
||||
@@ -0,0 +1,579 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const mail2925Utils = require('../mail2925-utils.js');
|
||||
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
test('ensureMail2925MailboxSession reuses current mailbox page without sending login when page stays on mailList', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
let sendCalls = 0;
|
||||
let readyCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {
|
||||
readyCalls += 1;
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return { loggedIn: true };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
actionLabel: '步骤 8:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(sendCalls, 0);
|
||||
assert.equal(readyCalls, 0);
|
||||
assert.equal(result.result.usedExistingSession, true);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession does not require account-pool accounts when pool is off and mailbox page stays on mailList', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925UseAccountPool: false,
|
||||
mail2925Accounts: [],
|
||||
currentMail2925AccountId: null,
|
||||
};
|
||||
let sendCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return { loggedIn: true };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: null,
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: false,
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(sendCalls, 0);
|
||||
assert.equal(result.result.usedExistingSession, true);
|
||||
assert.equal(result.account, null);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession stops immediately when login page is detected and account pool is off', async () => {
|
||||
let currentState = {
|
||||
autoRunning: true,
|
||||
autoRunPhase: 'running',
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
const stopCalls = [];
|
||||
let sendCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/login/' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
requestStop: async (options = {}) => {
|
||||
stopCalls.push(options);
|
||||
},
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return { loggedIn: true };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: false,
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
}),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
|
||||
assert.equal(sendCalls, 0);
|
||||
assert.equal(stopCalls.length, 1);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession logs in when login page is detected and account pool is on', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
let sendCalls = 0;
|
||||
let readyCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/login/' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {
|
||||
readyCalls += 1;
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return { loggedIn: true, currentView: 'mailbox' };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(sendCalls, 1);
|
||||
assert.equal(readyCalls, 1);
|
||||
assert.equal(result.result.loggedIn, true);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession recovers after login-page navigation reload breaks the old content-script channel', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
let tabUrl = 'https://2925.com/login/';
|
||||
const events = {
|
||||
logs: [],
|
||||
readyCalls: 0,
|
||||
sendCalls: 0,
|
||||
waitCompleteCalls: 0,
|
||||
};
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: tabUrl, status: tabUrl.includes('/#/mailList') ? 'complete' : 'loading' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {
|
||||
events.readyCalls += 1;
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToContentScriptResilient: async () => {
|
||||
events.sendCalls += 1;
|
||||
if (events.sendCalls === 1) {
|
||||
throw new Error('Could not establish connection. Receiving end does not exist.');
|
||||
}
|
||||
return { loggedIn: true, currentView: 'mailbox', mailboxEmail: 'acc1@2925.com' };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
waitForTabComplete: async () => {
|
||||
events.waitCompleteCalls += 1;
|
||||
tabUrl = 'https://2925.com/#/mailList';
|
||||
return { id: 9, url: tabUrl, status: 'complete' };
|
||||
},
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(events.sendCalls, 2);
|
||||
assert.equal(events.readyCalls, 2);
|
||||
assert.equal(events.waitCompleteCalls, 1);
|
||||
assert.equal(result.result.loggedIn, true);
|
||||
const combinedLogs = events.logs.map(({ message }) => message).join('\n');
|
||||
assert.match(combinedLogs, /登录提交后页面发生跳转或重载/);
|
||||
assert.match(combinedLogs, /登录跳转恢复后当前标签地址:https:\/\/2925\.com\/#\/mailList/);
|
||||
assert.match(combinedLogs, /页面恢复完成,正在重新确认登录态/);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession relogs with selected account when mailbox page email mismatches and pool is on', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925UseAccountPool: true,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'target@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
const openedUrls = [];
|
||||
const sendPayloads = [];
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: openedUrls.at(-1) || 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async (_source, url) => {
|
||||
openedUrls.push(url);
|
||||
return 9;
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, message) => {
|
||||
sendPayloads.push(message.payload);
|
||||
if (sendPayloads.length === 1) {
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: 'wrong@2925.com',
|
||||
};
|
||||
}
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: 'target@2925.com',
|
||||
};
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
waitForTabUrlMatch: async () => ({ url: 'https://2925.com/login/' }),
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
expectedMailboxEmail: 'target@2925.com',
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(result.result.loggedIn, true);
|
||||
assert.deepStrictEqual(openedUrls, [
|
||||
'https://2925.com/#/mailList',
|
||||
'https://2925.com/login/',
|
||||
]);
|
||||
assert.equal(sendPayloads.length, 2);
|
||||
assert.equal(sendPayloads[0].allowLoginWhenOnLoginPage, true);
|
||||
assert.equal(sendPayloads[1].forceLogin, true);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession stops when mailbox page email mismatches and pool is off', async () => {
|
||||
let currentState = {
|
||||
autoRunning: true,
|
||||
autoRunPhase: 'running',
|
||||
mail2925UseAccountPool: false,
|
||||
mail2925Accounts: [],
|
||||
currentMail2925AccountId: null,
|
||||
};
|
||||
const stopCalls = [];
|
||||
let sendCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
requestStop: async (options = {}) => {
|
||||
stopCalls.push(options);
|
||||
},
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: 'wrong@2925.com',
|
||||
};
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => manager.ensureMail2925MailboxSession({
|
||||
accountId: null,
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: false,
|
||||
expectedMailboxEmail: 'target@2925.com',
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
}),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
|
||||
assert.equal(sendCalls, 1);
|
||||
assert.equal(stopCalls.length, 1);
|
||||
assert.match(stopCalls[0].logMessage, /与目标账号 target@2925\.com 不一致/);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession does not crash when mailbox page is reused but top email cannot be detected', async () => {
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925UseAccountPool: false,
|
||||
mail2925Accounts: [],
|
||||
currentMail2925AccountId: null,
|
||||
};
|
||||
let sendCalls = 0;
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
get: async () => ({ id: 9, url: 'https://2925.com/#/mailList' }),
|
||||
},
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => 9,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
sendCalls += 1;
|
||||
return {
|
||||
loggedIn: true,
|
||||
currentView: 'mailbox',
|
||||
mailboxEmail: '',
|
||||
};
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const result = await manager.ensureMail2925MailboxSession({
|
||||
accountId: null,
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: false,
|
||||
expectedMailboxEmail: 'target@2925.com',
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.equal(sendCalls, 1);
|
||||
assert.equal(result.account, null);
|
||||
assert.equal(result.result.usedExistingSession, true);
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const mail2925Utils = require('../mail2925-utils.js');
|
||||
|
||||
test('background mail2925 session uses /login/ as relogin entry url', () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
assert.match(source, /const MAIL2925_LOGIN_URL = 'https:\/\/2925\.com\/login\/';/);
|
||||
});
|
||||
|
||||
test('background mail2925 session keeps a long login response timeout and a separate page-recovery window', () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
assert.match(source, /const MAIL2925_LOGIN_MESSAGE_RETRY_WINDOW_MS = 15000;/);
|
||||
assert.match(source, /const MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS = 120000;/);
|
||||
assert.match(source, /const MAIL2925_LOGIN_PAGE_RECOVERY_TIMEOUT_MS = 120000;/);
|
||||
assert.match(source, /responseTimeoutMs:\s*MAIL2925_LOGIN_RESPONSE_TIMEOUT_MS,/);
|
||||
assert.match(source, /recoverMail2925LoginPageAfterTransportError/);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession waits 3 seconds before and after opening login page on force relogin', async () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
let currentState = {
|
||||
autoRunning: false,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
const events = {
|
||||
sleeps: [],
|
||||
openedUrls: [],
|
||||
readyCalls: 0,
|
||||
};
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: () => false,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
requestStop: async () => {},
|
||||
ensureContentScriptReadyOnTab: async () => {
|
||||
events.readyCalls += 1;
|
||||
},
|
||||
reuseOrCreateTab: async (_source, url) => {
|
||||
events.openedUrls.push(url);
|
||||
return 1;
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => ({ loggedIn: true }),
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
sleepWithStop: async (ms) => {
|
||||
events.sleeps.push(ms);
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
waitForTabUrlMatch: async () => ({ url: 'https://2925.com/login/' }),
|
||||
});
|
||||
|
||||
await manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: true,
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(events.openedUrls, ['https://2925.com/login/']);
|
||||
assert.deepStrictEqual(events.sleeps, [3000, 3000]);
|
||||
assert.equal(events.readyCalls, 1);
|
||||
});
|
||||
@@ -0,0 +1,339 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const mail2925Utils = require('../mail2925-utils.js');
|
||||
|
||||
test('background mail2925 session module exposes a factory', () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
assert.equal(typeof api?.createMail2925SessionManager, 'function');
|
||||
});
|
||||
|
||||
test('handleMail2925LimitReachedError disables current account and switches to the next one', async () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
let currentState = {
|
||||
mail2925UseAccountPool: true,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'current', email: 'current@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
{ id: 'next', email: 'next@2925.com', password: 'p2', enabled: true, lastUsedAt: 20 },
|
||||
]),
|
||||
currentMail2925AccountId: 'current',
|
||||
};
|
||||
const events = {
|
||||
logs: [],
|
||||
dataUpdates: [],
|
||||
tabOpens: 0,
|
||||
sessionChecks: 0,
|
||||
};
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
broadcastDataUpdate: (payload) => {
|
||||
events.dataUpdates.push(payload);
|
||||
},
|
||||
chrome: {
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
reuseOrCreateTab: async () => {
|
||||
events.tabOpens += 1;
|
||||
return 1;
|
||||
},
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
events.sessionChecks += 1;
|
||||
return { loggedIn: true };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
};
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const error = await manager.handleMail2925LimitReachedError(
|
||||
4,
|
||||
new Error('MAIL2925_LIMIT_REACHED::子邮箱已达上限邮箱')
|
||||
);
|
||||
|
||||
assert.match(error.message, /^MAIL2925_THREAD_TERMINATED::/);
|
||||
assert.equal(currentState.currentMail2925AccountId, 'next');
|
||||
assert.ok(events.tabOpens >= 1);
|
||||
assert.ok(events.sessionChecks >= 1);
|
||||
|
||||
const currentAccount = currentState.mail2925Accounts.find((account) => account.id === 'current');
|
||||
assert.equal(currentAccount.lastError, '子邮箱已达上限邮箱');
|
||||
assert.ok(currentAccount.disabledUntil > Date.now());
|
||||
});
|
||||
|
||||
test('handleMail2925LimitReachedError requests stop when no next mail2925 account is available', async () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
let currentState = {
|
||||
mail2925UseAccountPool: true,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'only', email: 'only@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'only',
|
||||
};
|
||||
const events = {
|
||||
stopCalls: [],
|
||||
};
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
requestStop: async (options = {}) => {
|
||||
events.stopCalls.push(options);
|
||||
},
|
||||
reuseOrCreateTab: async () => 1,
|
||||
sendToMailContentScriptResilient: async () => ({ loggedIn: true }),
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...payload,
|
||||
};
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = {
|
||||
...currentState,
|
||||
...updates,
|
||||
};
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const error = await manager.handleMail2925LimitReachedError(
|
||||
4,
|
||||
new Error('MAIL2925_LIMIT_REACHED::子邮箱已达上限邮箱')
|
||||
);
|
||||
|
||||
assert.equal(error.message, '流程已被用户停止。');
|
||||
assert.equal(currentState.currentMail2925AccountId, null);
|
||||
assert.equal(events.stopCalls.length, 1);
|
||||
assert.match(events.stopCalls[0].logMessage, /没有可切换的下一个账号/);
|
||||
});
|
||||
|
||||
test('ensureMail2925MailboxSession requests stop when auto run is active and login does not reach mailbox', async () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
let currentState = {
|
||||
autoRunning: true,
|
||||
autoRunPhase: 'running',
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
};
|
||||
const events = {
|
||||
stopCalls: [],
|
||||
};
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isAutoRunLockedState: (state) => Boolean(state?.autoRunning) && state?.autoRunPhase === 'running',
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
requestStop: async (options = {}) => {
|
||||
events.stopCalls.push(options);
|
||||
},
|
||||
reuseOrCreateTab: async () => 1,
|
||||
sendToMailContentScriptResilient: async () => ({ loggedIn: false }),
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => manager.ensureMail2925MailboxSession({
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: true,
|
||||
actionLabel: '步骤 4:确认 2925 邮箱登录态',
|
||||
}),
|
||||
/流程已被用户停止。/
|
||||
);
|
||||
|
||||
assert.equal(events.stopCalls.length, 1);
|
||||
assert.match(events.stopCalls[0].logMessage, /登录后仍未进入收件箱/);
|
||||
});
|
||||
|
||||
test('handleMail2925LimitReachedError stops immediately when account pool is off even if another account exists', async () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
let currentState = {
|
||||
mail2925UseAccountPool: false,
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'current', email: 'current@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
{ id: 'next', email: 'next@2925.com', password: 'p2', enabled: true, lastUsedAt: 20 },
|
||||
]),
|
||||
currentMail2925AccountId: 'current',
|
||||
};
|
||||
const events = {
|
||||
stopCalls: [],
|
||||
sessionChecks: 0,
|
||||
};
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {
|
||||
cookies: {
|
||||
getAll: async () => [],
|
||||
remove: async () => ({ ok: true }),
|
||||
},
|
||||
browsingData: {
|
||||
removeCookies: async () => {},
|
||||
},
|
||||
},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
requestStop: async (options = {}) => {
|
||||
events.stopCalls.push(options);
|
||||
},
|
||||
reuseOrCreateTab: async () => 1,
|
||||
sendToMailContentScriptResilient: async () => {
|
||||
events.sessionChecks += 1;
|
||||
return { loggedIn: true };
|
||||
},
|
||||
setPersistentSettings: async (payload) => {
|
||||
currentState = { ...currentState, ...payload };
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
const error = await manager.handleMail2925LimitReachedError(
|
||||
4,
|
||||
new Error('MAIL2925_LIMIT_REACHED::子邮箱已达上限邮箱')
|
||||
);
|
||||
|
||||
assert.equal(error.message, '流程已被用户停止。');
|
||||
assert.equal(events.sessionChecks, 0);
|
||||
assert.equal(events.stopCalls.length, 1);
|
||||
assert.equal(currentState.currentMail2925AccountId, 'current');
|
||||
});
|
||||
|
||||
test('setCurrentMail2925Account persists currentMail2925AccountId for browser restart restore', async () => {
|
||||
const source = fs.readFileSync('background/mail-2925-session.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundMail2925Session;`)(globalScope);
|
||||
|
||||
let currentState = {
|
||||
mail2925Accounts: mail2925Utils.normalizeMail2925Accounts([
|
||||
{ id: 'acc-1', email: 'acc1@2925.com', password: 'p1', enabled: true, lastUsedAt: 10 },
|
||||
]),
|
||||
currentMail2925AccountId: null,
|
||||
};
|
||||
const persistedUpdates = [];
|
||||
|
||||
const manager = api.createMail2925SessionManager({
|
||||
addLog: async () => {},
|
||||
broadcastDataUpdate: () => {},
|
||||
chrome: {},
|
||||
findMail2925Account: mail2925Utils.findMail2925Account,
|
||||
getMail2925AccountStatus: mail2925Utils.getMail2925AccountStatus,
|
||||
getState: async () => currentState,
|
||||
isMail2925AccountAvailable: mail2925Utils.isMail2925AccountAvailable,
|
||||
MAIL2925_LIMIT_COOLDOWN_MS: mail2925Utils.MAIL2925_LIMIT_COOLDOWN_MS,
|
||||
normalizeMail2925Account: mail2925Utils.normalizeMail2925Account,
|
||||
normalizeMail2925Accounts: mail2925Utils.normalizeMail2925Accounts,
|
||||
pickMail2925AccountForRun: mail2925Utils.pickMail2925AccountForRun,
|
||||
setPersistentSettings: async (payload) => {
|
||||
persistedUpdates.push(payload);
|
||||
},
|
||||
setState: async (updates) => {
|
||||
currentState = { ...currentState, ...updates };
|
||||
},
|
||||
throwIfStopped: () => {},
|
||||
upsertMail2925AccountInList: mail2925Utils.upsertMail2925AccountInList,
|
||||
});
|
||||
|
||||
await manager.setCurrentMail2925Account('acc-1');
|
||||
|
||||
assert.equal(currentState.currentMail2925AccountId, 'acc-1');
|
||||
assert.deepStrictEqual(persistedUpdates, [
|
||||
{ currentMail2925AccountId: 'acc-1' },
|
||||
]);
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const signupFlowSource = fs.readFileSync('background/signup-flow-helpers.js', 'utf8');
|
||||
const signupFlowGlobalScope = {};
|
||||
const signupFlowApi = new Function('self', `${signupFlowSource}; return self.MultiPageSignupFlowHelpers;`)(signupFlowGlobalScope);
|
||||
|
||||
test('signup flow helper allocates mail2925 account before generating alias email', async () => {
|
||||
const calls = {
|
||||
ensureMail2925: [],
|
||||
buildAlias: 0,
|
||||
setEmail: [],
|
||||
};
|
||||
|
||||
const helpers = signupFlowApi.createSignupFlowHelpers({
|
||||
buildGeneratedAliasEmail: (state) => {
|
||||
calls.buildAlias += 1;
|
||||
assert.equal(state.currentMail2925AccountId, 'acc-2');
|
||||
return 'demo123456@2925.com';
|
||||
},
|
||||
chrome: { tabs: { get: async () => ({ id: 1, url: 'https://auth.openai.com/create-account/password' }) } },
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
ensureHotmailAccountForFlow: async () => ({}),
|
||||
ensureMail2925AccountForFlow: async (options) => {
|
||||
calls.ensureMail2925.push(options);
|
||||
return { id: 'acc-2', email: 'demo@2925.com' };
|
||||
},
|
||||
ensureLuckmailPurchaseForFlow: async () => ({}),
|
||||
isGeneratedAliasProvider: () => true,
|
||||
isReusableGeneratedAliasEmail: () => false,
|
||||
isHotmailProvider: () => false,
|
||||
isLuckmailProvider: () => false,
|
||||
isSignupEmailVerificationPageUrl: () => false,
|
||||
isSignupPasswordPageUrl: () => true,
|
||||
reuseOrCreateTab: async () => 1,
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
setEmailState: async (email) => {
|
||||
calls.setEmail.push(email);
|
||||
},
|
||||
SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
|
||||
SIGNUP_PAGE_INJECT_FILES: [],
|
||||
waitForTabUrlMatch: async () => null,
|
||||
});
|
||||
|
||||
const email = await helpers.resolveSignupEmailForFlow({
|
||||
mailProvider: '2925',
|
||||
mail2925UseAccountPool: true,
|
||||
currentMail2925AccountId: 'acc-2',
|
||||
email: '',
|
||||
});
|
||||
|
||||
assert.equal(email, 'demo123456@2925.com');
|
||||
assert.deepStrictEqual(calls.ensureMail2925, [
|
||||
{
|
||||
allowAllocate: true,
|
||||
preferredAccountId: 'acc-2',
|
||||
markUsed: true,
|
||||
},
|
||||
]);
|
||||
assert.equal(calls.buildAlias, 1);
|
||||
assert.deepStrictEqual(calls.setEmail, ['demo123456@2925.com']);
|
||||
});
|
||||
|
||||
test('signup flow helper skips mail2925 account allocation when account pool switch is off', async () => {
|
||||
const calls = {
|
||||
ensureMail2925: 0,
|
||||
};
|
||||
|
||||
const helpers = signupFlowApi.createSignupFlowHelpers({
|
||||
buildGeneratedAliasEmail: () => 'manual123456@2925.com',
|
||||
chrome: { tabs: { get: async () => ({ id: 1, url: 'https://auth.openai.com/create-account/password' }) } },
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
ensureHotmailAccountForFlow: async () => ({}),
|
||||
ensureMail2925AccountForFlow: async () => {
|
||||
calls.ensureMail2925 += 1;
|
||||
return { id: 'acc-2', email: 'demo@2925.com' };
|
||||
},
|
||||
ensureLuckmailPurchaseForFlow: async () => ({}),
|
||||
isGeneratedAliasProvider: () => true,
|
||||
isReusableGeneratedAliasEmail: () => false,
|
||||
isHotmailProvider: () => false,
|
||||
isLuckmailProvider: () => false,
|
||||
isSignupEmailVerificationPageUrl: () => false,
|
||||
isSignupPasswordPageUrl: () => true,
|
||||
reuseOrCreateTab: async () => 1,
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
setEmailState: async () => {},
|
||||
SIGNUP_ENTRY_URL: 'https://chatgpt.com/',
|
||||
SIGNUP_PAGE_INJECT_FILES: [],
|
||||
waitForTabUrlMatch: async () => null,
|
||||
});
|
||||
|
||||
const email = await helpers.resolveSignupEmailForFlow({
|
||||
mailProvider: '2925',
|
||||
mail2925UseAccountPool: false,
|
||||
currentMail2925AccountId: 'acc-2',
|
||||
email: '',
|
||||
});
|
||||
|
||||
assert.equal(email, 'manual123456@2925.com');
|
||||
assert.equal(calls.ensureMail2925, 0);
|
||||
});
|
||||
@@ -15,6 +15,8 @@ function createRouter(overrides = {}) {
|
||||
notifyCompletions: [],
|
||||
notifyErrors: [],
|
||||
securityBlocks: [],
|
||||
invalidations: [],
|
||||
executedSteps: [],
|
||||
};
|
||||
|
||||
const router = api.createMessageRouter({
|
||||
@@ -41,7 +43,9 @@ function createRouter(overrides = {}) {
|
||||
disableUsedLuckmailPurchases: async () => {},
|
||||
doesStepUseCompletionSignal: () => false,
|
||||
ensureManualInteractionAllowed: async () => ({}),
|
||||
executeStep: async () => {},
|
||||
executeStep: async (step) => {
|
||||
events.executedSteps.push(step);
|
||||
},
|
||||
executeStepViaCompletionSignal: async () => {},
|
||||
exportSettingsBundle: async () => ({}),
|
||||
fetchGeneratedEmail: async () => '',
|
||||
@@ -55,6 +59,7 @@ function createRouter(overrides = {}) {
|
||||
getPendingAutoRunTimerPlan: () => null,
|
||||
getSourceLabel: () => '',
|
||||
getState: async () => overrides.state || { stepStatuses: { 3: 'pending' } },
|
||||
getTabId: overrides.getTabId || (async () => null),
|
||||
getStopRequested: () => false,
|
||||
handleAutoRunLoopUnhandledError: async () => {},
|
||||
handleCloudflareSecurityBlocked: overrides.handleCloudflareSecurityBlocked || (async (error) => {
|
||||
@@ -63,13 +68,16 @@ function createRouter(overrides = {}) {
|
||||
return message.replace(/^CF_SECURITY_BLOCKED::/, '') || message;
|
||||
}),
|
||||
importSettingsBundle: async () => {},
|
||||
invalidateDownstreamAfterStepRestart: async () => {},
|
||||
invalidateDownstreamAfterStepRestart: async (step, options) => {
|
||||
events.invalidations.push({ step, options });
|
||||
},
|
||||
isCloudflareSecurityBlockedError: overrides.isCloudflareSecurityBlockedError || ((error) => /^CF_SECURITY_BLOCKED::/.test(typeof error === 'string' ? error : error?.message || '')),
|
||||
isAutoRunLockedState: () => false,
|
||||
isHotmailProvider: () => false,
|
||||
isLocalhostOAuthCallbackUrl: () => true,
|
||||
isLuckmailProvider: () => false,
|
||||
isStopError: () => false,
|
||||
isTabAlive: overrides.isTabAlive || (async () => false),
|
||||
launchAutoRunTimerPlan: async () => {},
|
||||
listIcloudAliases: async () => [],
|
||||
listLuckmailPurchasesForManagement: async () => [],
|
||||
@@ -226,3 +234,22 @@ test('message router stops the flow and surfaces cloudflare security block error
|
||||
error: '您已触发Cloudflare 安全防护系统',
|
||||
});
|
||||
});
|
||||
|
||||
test('message router blocks manual step 4 execution when signup page tab is missing', async () => {
|
||||
const { router, events } = createRouter({
|
||||
getTabId: async () => null,
|
||||
isTabAlive: async () => false,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => router.handleMessage({
|
||||
type: 'EXECUTE_STEP',
|
||||
source: 'sidepanel',
|
||||
payload: { step: 4 },
|
||||
}, {}),
|
||||
/手动执行步骤 4 前,请先执行步骤 1 或步骤 2/
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(events.invalidations, []);
|
||||
assert.deepStrictEqual(events.executedSteps, []);
|
||||
});
|
||||
|
||||
@@ -35,3 +35,23 @@ test('navigation utils treat 126 mail hosts as part of the shared NetEase mail f
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test('navigation utils support codex2api mode and url normalization', () => {
|
||||
const source = fs.readFileSync('background/navigation-utils.js', 'utf8');
|
||||
const globalScope = {};
|
||||
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundNavigationUtils;`)(globalScope);
|
||||
const utils = api.createNavigationUtils({
|
||||
DEFAULT_CODEX2API_URL: 'http://localhost:8080/admin/accounts',
|
||||
DEFAULT_SUB2API_URL: 'https://sub.example.com/admin/accounts',
|
||||
normalizeLocalCpaStep9Mode: (value) => value,
|
||||
});
|
||||
|
||||
assert.equal(utils.normalizeCodex2ApiUrl('localhost:8080/admin'), 'http://localhost:8080/admin/accounts');
|
||||
assert.equal(
|
||||
utils.normalizeCodex2ApiUrl('https://codex-admin.example.com/'),
|
||||
'https://codex-admin.example.com/admin/accounts'
|
||||
);
|
||||
assert.equal(utils.getPanelMode({ panelMode: 'codex2api' }), 'codex2api');
|
||||
assert.equal(utils.getPanelModeLabel('codex2api'), 'Codex2API');
|
||||
});
|
||||
|
||||
@@ -15,3 +15,59 @@ test('panel bridge module exposes a factory', () => {
|
||||
|
||||
assert.equal(typeof api?.createPanelBridge, 'function');
|
||||
});
|
||||
|
||||
test('panel bridge requests oauth url with step 7 log label payload', () => {
|
||||
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
||||
assert.match(source, /logStep:\s*7/);
|
||||
assert.doesNotMatch(source, /logStep:\s*6/);
|
||||
});
|
||||
|
||||
test('panel bridge can request codex2api oauth url via protocol', async () => {
|
||||
const source = fs.readFileSync('background/panel-bridge.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
assert.equal(url, 'http://localhost:8080/api/admin/oauth/generate-auth-url');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.equal(options.headers['X-Admin-Key'], 'admin-secret');
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
auth_url: 'https://auth.openai.com/authorize?state=oauth-state',
|
||||
session_id: 'session-123',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundPanelBridge;`)({});
|
||||
const bridge = api.createPanelBridge({
|
||||
addLog: async () => {},
|
||||
chrome: {},
|
||||
closeConflictingTabsForSource: async () => {},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
getPanelMode: () => 'codex2api',
|
||||
normalizeCodex2ApiUrl: (value) => value ? `http://${value.replace(/^https?:\/\//, '')}`.replace(/\/admin$/, '/admin/accounts') : 'http://localhost:8080/admin/accounts',
|
||||
normalizeSub2ApiUrl: (value) => value,
|
||||
rememberSourceLastUrl: async () => {},
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
waitForTabUrlFamily: async () => null,
|
||||
DEFAULT_SUB2API_GROUP_NAME: 'codex',
|
||||
SUB2API_STEP1_RESPONSE_TIMEOUT_MS: 90000,
|
||||
});
|
||||
|
||||
const result = await bridge.requestOAuthUrlFromPanel({
|
||||
panelMode: 'codex2api',
|
||||
codex2apiUrl: 'localhost:8080/admin',
|
||||
codex2apiAdminKey: 'admin-secret',
|
||||
}, { logLabel: '步骤 7' });
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
oauthUrl: 'https://auth.openai.com/authorize?state=oauth-state',
|
||||
codex2apiSessionId: 'session-123',
|
||||
codex2apiOAuthState: 'oauth-state',
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const test = require('node:test');
|
||||
|
||||
test('platform verify module supports codex2api protocol callback exchange', async () => {
|
||||
const source = fs.readFileSync('background/steps/platform-verify.js', 'utf8');
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
assert.equal(url, 'http://localhost:8080/api/admin/oauth/exchange-code');
|
||||
assert.equal(options.method, 'POST');
|
||||
assert.equal(options.headers['X-Admin-Key'], 'admin-secret');
|
||||
assert.deepStrictEqual(JSON.parse(options.body), {
|
||||
session_id: 'session-123',
|
||||
code: 'callback-code',
|
||||
state: 'oauth-state',
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
message: 'OAuth 账号 flow@example.com 添加成功',
|
||||
id: 42,
|
||||
email: 'flow@example.com',
|
||||
plan_type: 'pro',
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep10;`)({});
|
||||
const completed = [];
|
||||
const logs = [];
|
||||
const executor = api.createStep10Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
},
|
||||
chrome: {},
|
||||
closeConflictingTabsForSource: async () => {},
|
||||
completeStepFromBackground: async (step, payload) => {
|
||||
completed.push({ step, payload });
|
||||
},
|
||||
ensureContentScriptReadyOnTab: async () => {},
|
||||
getPanelMode: () => 'codex2api',
|
||||
getTabId: async () => 0,
|
||||
isLocalhostOAuthCallbackUrl: (value) => String(value || '').includes('/auth/callback?code='),
|
||||
isTabAlive: async () => false,
|
||||
normalizeCodex2ApiUrl: () => 'http://localhost:8080/admin/accounts',
|
||||
normalizeSub2ApiUrl: (value) => value,
|
||||
rememberSourceLastUrl: async () => {},
|
||||
reuseOrCreateTab: async () => 0,
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
shouldBypassStep9ForLocalCpa: () => false,
|
||||
SUB2API_STEP9_RESPONSE_TIMEOUT_MS: 120000,
|
||||
});
|
||||
|
||||
await executor.executeStep10({
|
||||
panelMode: 'codex2api',
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
codex2apiUrl: 'http://localhost:8080/admin/accounts',
|
||||
codex2apiAdminKey: 'admin-secret',
|
||||
codex2apiSessionId: 'session-123',
|
||||
codex2apiOAuthState: 'oauth-state',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(logs, [
|
||||
{ message: '步骤 10:正在向 Codex2API 提交回调并创建账号...', level: 'info' },
|
||||
{ message: '步骤 10:OAuth 账号 flow@example.com 添加成功', level: 'ok' },
|
||||
]);
|
||||
assert.deepStrictEqual(completed, [
|
||||
{
|
||||
step: 10,
|
||||
payload: {
|
||||
localhostUrl: 'http://localhost:1455/auth/callback?code=callback-code&state=oauth-state',
|
||||
verifiedStatus: 'OAuth 账号 flow@example.com 添加成功',
|
||||
},
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('background/steps/fetch-signup-code.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep4;`)(globalScope);
|
||||
|
||||
test('step 4 passes a fixed 10-minute lookback window to 2925 mailbox polling', async () => {
|
||||
let capturedOptions = null;
|
||||
let ensureCalls = 0;
|
||||
const tabUpdates = [];
|
||||
const tabReuses = [];
|
||||
const realDateNow = Date.now;
|
||||
Date.now = () => 700000;
|
||||
|
||||
const executor = api.createStep4Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async (tabId, payload) => {
|
||||
tabUpdates.push({ tabId, payload });
|
||||
},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureMail2925MailboxSession: async () => {
|
||||
ensureCalls += 1;
|
||||
},
|
||||
getMailConfig: () => ({
|
||||
provider: '2925',
|
||||
label: '2925 邮箱',
|
||||
source: 'mail-2925',
|
||||
url: 'https://2925.com',
|
||||
}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
resolveVerificationStep: async (_step, _state, _mail, options) => {
|
||||
capturedOptions = options;
|
||||
},
|
||||
reuseOrCreateTab: async (source, url) => {
|
||||
tabReuses.push({ source, url });
|
||||
},
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
try {
|
||||
await executor.executeStep4({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
mail2925UseAccountPool: true,
|
||||
});
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
assert.equal(ensureCalls, 1);
|
||||
assert.deepStrictEqual(tabReuses, []);
|
||||
assert.deepStrictEqual(tabUpdates, [
|
||||
{ tabId: 1, payload: { active: true } },
|
||||
]);
|
||||
assert.equal(capturedOptions.filterAfterTimestamp, 100000);
|
||||
assert.equal(capturedOptions.resendIntervalMs, 0);
|
||||
});
|
||||
|
||||
test('step 4 does not request a fresh code first for Cloudflare temp mail', async () => {
|
||||
let capturedOptions = null;
|
||||
const realDateNow = Date.now;
|
||||
Date.now = () => 700000;
|
||||
|
||||
const executor = api.createStep4Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureMail2925MailboxSession: async () => {},
|
||||
getMailConfig: () => ({
|
||||
provider: 'cloudflare-temp-email',
|
||||
label: 'Cloudflare Temp Email',
|
||||
source: 'cloudflare-temp-email',
|
||||
url: 'https://temp.peekcart.com',
|
||||
}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
resolveVerificationStep: async (_step, _state, _mail, options) => {
|
||||
capturedOptions = options;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async () => ({}),
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
try {
|
||||
await executor.executeStep4({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
});
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
assert.equal(capturedOptions.filterAfterTimestamp, 700000);
|
||||
assert.equal(capturedOptions.requestFreshCodeFirst, false);
|
||||
assert.equal(capturedOptions.resendIntervalMs, 25000);
|
||||
});
|
||||
@@ -172,7 +172,93 @@ test('step 7 starts a new oauth timeout window for each refreshed oauth url', as
|
||||
options: {
|
||||
step: 7,
|
||||
actionLabel: 'OAuth 登录并进入验证码页',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 7 stops immediately when management secret is missing', async () => {
|
||||
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
|
||||
|
||||
const events = {
|
||||
refreshCalls: 0,
|
||||
sendCalls: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep7Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||||
refreshOAuthUrlBeforeStep6: async () => {
|
||||
events.refreshCalls += 1;
|
||||
throw new Error('尚未配置 Codex2API 管理密钥,请先在侧边栏填写。');
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async () => {
|
||||
events.sendCalls += 1;
|
||||
return { step6Outcome: 'success' };
|
||||
},
|
||||
STEP6_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
|
||||
/管理密钥/
|
||||
);
|
||||
|
||||
assert.equal(events.refreshCalls, 1);
|
||||
assert.equal(events.sendCalls, 0);
|
||||
assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
|
||||
assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
|
||||
});
|
||||
|
||||
test('step 7 stops immediately when management secret is invalid', async () => {
|
||||
const source = fs.readFileSync('background/steps/oauth-login.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundStep7;`)(globalScope);
|
||||
|
||||
const events = {
|
||||
refreshCalls: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
const executor = api.createStep7Executor({
|
||||
addLog: async (message, level = 'info') => {
|
||||
events.logs.push({ message, level });
|
||||
},
|
||||
completeStepFromBackground: async () => {},
|
||||
getErrorMessage: (error) => error?.message || String(error || ''),
|
||||
getLoginAuthStateLabel: (state) => state || 'unknown',
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
isStep6RecoverableResult: (result) => result?.step6Outcome === 'recoverable',
|
||||
isStep6SuccessResult: (result) => result?.step6Outcome === 'success',
|
||||
refreshOAuthUrlBeforeStep6: async () => {
|
||||
events.refreshCalls += 1;
|
||||
throw new Error('Codex2API 请求失败(HTTP 401)。X-Admin-Key 无效或未授权。');
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
sendToContentScriptResilient: async () => ({ step6Outcome: 'success' }),
|
||||
STEP6_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
() => executor.executeStep7({ email: 'user@example.com', password: 'secret' }),
|
||||
/401|未授权|无效/
|
||||
);
|
||||
|
||||
assert.equal(events.refreshCalls, 1);
|
||||
assert.ok(events.logs.some(({ message }) => /管理密钥缺失或错误,不再重试,当前流程停止/.test(message)));
|
||||
assert.ok(!events.logs.some(({ message }) => /准备重试/.test(message)));
|
||||
});
|
||||
|
||||
@@ -10,8 +10,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
const calls = {
|
||||
ensureReady: 0,
|
||||
ensureReadyOptions: [],
|
||||
executeStep7: 0,
|
||||
sleep: [],
|
||||
rerunStep7: 0,
|
||||
resolveOptions: null,
|
||||
setStates: [],
|
||||
};
|
||||
@@ -32,8 +31,8 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
calls.ensureReadyOptions.push(options || null);
|
||||
return { state: 'verification_page', displayedEmail: 'display.user@example.com' };
|
||||
},
|
||||
executeStep7: async () => {
|
||||
calls.executeStep7 += 1;
|
||||
rerunStep7ForStep8Recovery: async () => {
|
||||
calls.rerunStep7 += 1;
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 5000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 5000),
|
||||
@@ -59,9 +58,6 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async (ms) => {
|
||||
calls.sleep.push(ms);
|
||||
},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
@@ -79,8 +75,7 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
|
||||
assert.equal(calls.resolveOptions.beforeSubmit, undefined);
|
||||
assert.equal(calls.ensureReady, 1);
|
||||
assert.equal(calls.executeStep7, 0);
|
||||
assert.deepStrictEqual(calls.sleep, []);
|
||||
assert.equal(calls.rerunStep7, 0);
|
||||
assert.equal(calls.resolveOptions.filterAfterTimestamp, 123456);
|
||||
assert.equal(typeof calls.resolveOptions.getRemainingTimeMs, 'function');
|
||||
assert.equal(await calls.resolveOptions.getRemainingTimeMs({ actionLabel: '登录验证码流程' }), 5000);
|
||||
@@ -94,20 +89,32 @@ test('step 8 submits login verification directly without replaying step 7', asyn
|
||||
]);
|
||||
});
|
||||
|
||||
test('step 8 disables resend interval for 2925 mailbox polling', async () => {
|
||||
test('step 8 uses a fixed 10-minute lookback window and disables resend interval for 2925 mailbox polling', async () => {
|
||||
let capturedOptions = null;
|
||||
let ensureCalls = 0;
|
||||
let ensureOptions = null;
|
||||
const tabUpdates = [];
|
||||
const tabReuses = [];
|
||||
const realDateNow = Date.now;
|
||||
Date.now = () => 900000;
|
||||
|
||||
const executor = api.createStep8Executor({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
update: async (tabId, payload) => {
|
||||
tabUpdates.push({ tabId, payload });
|
||||
},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureMail2925MailboxSession: async (options) => {
|
||||
ensureCalls += 1;
|
||||
ensureOptions = options;
|
||||
},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
|
||||
executeStep7: async () => {},
|
||||
rerunStep7ForStep8Recovery: async () => {},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
getMailConfig: () => ({
|
||||
@@ -117,7 +124,15 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
|
||||
url: 'https://2925.com',
|
||||
navigateOnReuse: false,
|
||||
}),
|
||||
getState: async () => ({ email: 'user@example.com', password: 'secret' }),
|
||||
getState: async () => ({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
mail2925UseAccountPool: true,
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
mail2925Accounts: [
|
||||
{ id: 'acc-1', email: 'pool-user@2925.com' },
|
||||
],
|
||||
}),
|
||||
getTabId: async (sourceName) => (sourceName === 'signup-page' ? 1 : 2),
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isTabAlive: async () => true,
|
||||
@@ -126,22 +141,45 @@ test('step 8 disables resend interval for 2925 mailbox polling', async () => {
|
||||
resolveVerificationStep: async (_step, _state, _mail, options) => {
|
||||
capturedOptions = options;
|
||||
},
|
||||
reuseOrCreateTab: async () => {},
|
||||
reuseOrCreateTab: async (source, url) => {
|
||||
tabReuses.push({ source, url });
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
});
|
||||
|
||||
try {
|
||||
await executor.executeStep8({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
mail2925UseAccountPool: true,
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
mail2925Accounts: [
|
||||
{ id: 'acc-1', email: 'pool-user@2925.com' },
|
||||
],
|
||||
});
|
||||
} finally {
|
||||
Date.now = realDateNow;
|
||||
}
|
||||
|
||||
assert.equal(ensureCalls, 1);
|
||||
assert.deepStrictEqual(ensureOptions, {
|
||||
accountId: 'acc-1',
|
||||
forceRelogin: false,
|
||||
allowLoginWhenOnLoginPage: true,
|
||||
expectedMailboxEmail: 'pool-user@2925.com',
|
||||
actionLabel: 'Step 8: ensure 2925 mailbox session',
|
||||
});
|
||||
assert.deepStrictEqual(tabReuses, []);
|
||||
assert.deepStrictEqual(tabUpdates, [
|
||||
{ tabId: 1, payload: { active: true } },
|
||||
]);
|
||||
assert.equal(capturedOptions.filterAfterTimestamp, 300000);
|
||||
assert.equal(capturedOptions.resendIntervalMs, 0);
|
||||
assert.equal(capturedOptions.targetEmail, '');
|
||||
assert.equal(capturedOptions.beforeSubmit, undefined);
|
||||
@@ -161,7 +199,7 @@ test('step 8 falls back to the run email when the verification page does not exp
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page', displayedEmail: '' }),
|
||||
executeStep7: async () => {},
|
||||
rerunStep7ForStep8Recovery: async () => {},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
getMailConfig: () => ({
|
||||
@@ -184,7 +222,6 @@ test('step 8 falls back to the run email when the verification page does not exp
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
@@ -201,7 +238,7 @@ test('step 8 falls back to the run email when the verification page does not exp
|
||||
|
||||
test('step 8 does not rerun step 7 when verification submit lands on add-phone', async () => {
|
||||
const calls = {
|
||||
executeStep7: 0,
|
||||
rerunStep7: 0,
|
||||
logs: [],
|
||||
};
|
||||
|
||||
@@ -217,8 +254,8 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone',
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
confirmCustomVerificationStepBypass: async () => {},
|
||||
ensureStep8VerificationPageReady: async () => ({ state: 'verification_page' }),
|
||||
executeStep7: async () => {
|
||||
calls.executeStep7 += 1;
|
||||
rerunStep7ForStep8Recovery: async () => {
|
||||
calls.rerunStep7 += 1;
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
@@ -242,7 +279,6 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone',
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async () => {},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 8,
|
||||
throwIfStopped: () => {},
|
||||
@@ -257,6 +293,6 @@ test('step 8 does not rerun step 7 when verification submit lands on add-phone',
|
||||
/add-phone/
|
||||
);
|
||||
|
||||
assert.equal(calls.executeStep7, 0);
|
||||
assert.equal(calls.rerunStep7, 0);
|
||||
assert.ok(!calls.logs.some(({ message }) => /准备从步骤 7 重新开始/.test(message)));
|
||||
});
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
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;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
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('generic stopped record resolves to next unfinished step during execution gap', async () => {
|
||||
const bundle = [
|
||||
extractFunction('getRunningSteps'),
|
||||
extractFunction('inferStoppedRecordStep'),
|
||||
extractFunction('resolveAccountRunRecordStatusForStop'),
|
||||
extractFunction('extractStoppedStepFromRecordStatus'),
|
||||
extractFunction('resolveAccountRunRecordReasonForStop'),
|
||||
extractFunction('appendAndBroadcastAccountRunRecord'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const STEP_IDS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const DEFAULT_STATE = {
|
||||
stepStatuses: Object.fromEntries(STEP_IDS.map((step) => [step, 'pending'])),
|
||||
};
|
||||
let captured = null;
|
||||
const accountRunHistoryHelpers = {
|
||||
appendAccountRunRecord: async (status, state, reason) => {
|
||||
captured = { status, state, reason };
|
||||
return { status, state, reason };
|
||||
},
|
||||
};
|
||||
async function broadcastAccountRunHistoryUpdate() {}
|
||||
async function getState() {
|
||||
return {};
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
inferStoppedRecordStep,
|
||||
resolveAccountRunRecordStatusForStop,
|
||||
resolveAccountRunRecordReasonForStop,
|
||||
appendAndBroadcastAccountRunRecord,
|
||||
getCaptured() {
|
||||
return captured;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const state = {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
stepStatuses: {
|
||||
1: 'completed',
|
||||
2: 'completed',
|
||||
3: 'completed',
|
||||
4: 'completed',
|
||||
5: 'completed',
|
||||
6: 'completed',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(api.inferStoppedRecordStep(state), 7);
|
||||
assert.equal(api.resolveAccountRunRecordStatusForStop('stopped', state), 'step7_stopped');
|
||||
assert.equal(api.resolveAccountRunRecordReasonForStop('step7_stopped', '流程已被用户停止。'), '步骤 7 已被用户停止。');
|
||||
assert.equal(
|
||||
api.resolveAccountRunRecordReasonForStop('step2_stopped', '步骤 2 已使用邮箱,流程尚未完成。'),
|
||||
'步骤 2 已停止:邮箱已设置,流程尚未完成。'
|
||||
);
|
||||
|
||||
await api.appendAndBroadcastAccountRunRecord('stopped', state, '流程已被用户停止。');
|
||||
assert.deepStrictEqual(api.getCaptured(), {
|
||||
status: 'step7_stopped',
|
||||
state,
|
||||
reason: '步骤 7 已被用户停止。',
|
||||
});
|
||||
});
|
||||
|
||||
test('requestStop appends a stopped record for the next unfinished step when no step is running', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeAutoRunSessionId'),
|
||||
extractFunction('clearCurrentAutoRunSessionId'),
|
||||
extractFunction('cleanupStep8NavigationListeners'),
|
||||
extractFunction('rejectPendingStep8'),
|
||||
extractFunction('getRunningSteps'),
|
||||
extractFunction('inferStoppedRecordStep'),
|
||||
extractFunction('requestStop'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let stopRequested = false;
|
||||
let autoRunActive = false;
|
||||
let autoRunCurrentRun = 0;
|
||||
let autoRunTotalRuns = 1;
|
||||
let autoRunAttemptRun = 0;
|
||||
let autoRunSessionId = 99;
|
||||
let webNavListener = null;
|
||||
let webNavCommittedListener = null;
|
||||
let step8TabUpdatedListener = null;
|
||||
let step8PendingReject = null;
|
||||
let resumeWaiter = null;
|
||||
const STOP_ERROR_MESSAGE = '流程已被用户停止。';
|
||||
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
|
||||
const DEFAULT_STATE = {
|
||||
stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])),
|
||||
};
|
||||
const stepWaiters = new Map();
|
||||
const appended = [];
|
||||
const logs = [];
|
||||
const chrome = {
|
||||
webNavigation: {
|
||||
onBeforeNavigate: { removeListener() {} },
|
||||
onCommitted: { removeListener() {} },
|
||||
},
|
||||
tabs: {
|
||||
onUpdated: { removeListener() {} },
|
||||
},
|
||||
};
|
||||
|
||||
function cancelPendingCommands() {}
|
||||
function getPendingAutoRunTimerPlan() {
|
||||
return null;
|
||||
}
|
||||
async function cancelScheduledAutoRun() {}
|
||||
async function clearAutoRunTimerAlarm() {}
|
||||
function clearStopRequest() {
|
||||
stopRequested = false;
|
||||
}
|
||||
async function addLog(message, level) {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
async function broadcastStopToContentScripts() {}
|
||||
async function markRunningStepsStopped() {}
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function appendAndBroadcastAccountRunRecord(status, state, reason) {
|
||||
appended.push({ status, state, reason });
|
||||
return { status, state, reason };
|
||||
}
|
||||
async function getState() {
|
||||
return {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
stepStatuses: {
|
||||
1: 'completed',
|
||||
2: 'completed',
|
||||
3: 'completed',
|
||||
4: 'completed',
|
||||
5: 'completed',
|
||||
6: 'completed',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
requestStop,
|
||||
snapshot() {
|
||||
return { appended, logs, stopRequested, autoRunSessionId };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await api.requestStop();
|
||||
const state = api.snapshot();
|
||||
|
||||
assert.deepStrictEqual(state.appended, [{
|
||||
status: 'stopped',
|
||||
state: {
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
stepStatuses: {
|
||||
1: 'completed',
|
||||
2: 'completed',
|
||||
3: 'completed',
|
||||
4: 'completed',
|
||||
5: 'completed',
|
||||
6: 'completed',
|
||||
7: 'pending',
|
||||
8: 'pending',
|
||||
9: 'pending',
|
||||
10: 'pending',
|
||||
},
|
||||
},
|
||||
reason: '流程已被用户停止。',
|
||||
}]);
|
||||
assert.equal(state.autoRunSessionId, 0);
|
||||
assert.equal(state.stopRequested, true);
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('sidepanel/contribution-content-update-service.js', 'utf8');
|
||||
|
||||
function createContributionContentService(options = {}) {
|
||||
const cache = new Map();
|
||||
const windowObject = {};
|
||||
let fetchCalls = 0;
|
||||
|
||||
const localStorage = {
|
||||
getItem(key) {
|
||||
return cache.has(key) ? cache.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
cache.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
cache.delete(key);
|
||||
},
|
||||
};
|
||||
|
||||
if (options.cachedSnapshot) {
|
||||
cache.set(
|
||||
'multipage-contribution-content-summary-v1',
|
||||
JSON.stringify(options.cachedSnapshot)
|
||||
);
|
||||
}
|
||||
|
||||
const fetchImpl = options.fetchImpl || (async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
ok: true,
|
||||
items: [],
|
||||
prompt_version: '',
|
||||
has_visible_updates: false,
|
||||
latest_updated_at: '',
|
||||
latest_updated_at_display: '',
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const wrappedFetch = async (...args) => {
|
||||
fetchCalls += 1;
|
||||
return fetchImpl(...args);
|
||||
};
|
||||
|
||||
const api = new Function(
|
||||
'window',
|
||||
'localStorage',
|
||||
'fetch',
|
||||
'AbortController',
|
||||
'setTimeout',
|
||||
'clearTimeout',
|
||||
`${source}; return window.SidepanelContributionContentService;`
|
||||
)(
|
||||
windowObject,
|
||||
localStorage,
|
||||
wrappedFetch,
|
||||
AbortController,
|
||||
setTimeout,
|
||||
clearTimeout
|
||||
);
|
||||
|
||||
return {
|
||||
api,
|
||||
getFetchCalls() {
|
||||
return fetchCalls;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('getContentUpdateSnapshot returns a prompt version for visible contribution content updates', async () => {
|
||||
const { api } = createContributionContentService({
|
||||
fetchImpl: async () => ({
|
||||
ok: true,
|
||||
async json() {
|
||||
return {
|
||||
ok: true,
|
||||
prompt_version: 'announcement:2026-04-21T12:00:00Z|tutorial:2026-04-21T12:05:00Z',
|
||||
has_visible_updates: true,
|
||||
latest_updated_at: '2026-04-21T12:05:00Z',
|
||||
latest_updated_at_display: '2026-04-21 20:05',
|
||||
items: [
|
||||
{
|
||||
slug: 'announcement',
|
||||
title: '最新公告',
|
||||
is_enabled: true,
|
||||
has_content: true,
|
||||
is_visible: true,
|
||||
updated_at: '2026-04-21T12:00:00Z',
|
||||
updated_at_display: '2026-04-21 20:00',
|
||||
},
|
||||
{
|
||||
slug: 'tutorial',
|
||||
title: '使用教程',
|
||||
is_enabled: true,
|
||||
has_content: true,
|
||||
is_visible: true,
|
||||
updated_at: '2026-04-21T12:05:00Z',
|
||||
updated_at_display: '2026-04-21 20:05',
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const snapshot = await api.getContentUpdateSnapshot();
|
||||
|
||||
assert.equal(snapshot.status, 'update-available');
|
||||
assert.equal(snapshot.promptVersion, 'announcement:2026-04-21T12:00:00Z|tutorial:2026-04-21T12:05:00Z');
|
||||
assert.equal(snapshot.hasVisibleUpdates, true);
|
||||
assert.equal(snapshot.latestUpdatedAt, '2026-04-21T12:05:00Z');
|
||||
assert.equal(snapshot.items.length, 2);
|
||||
assert.deepEqual(
|
||||
snapshot.items.map((item) => [item.slug, item.isVisible]),
|
||||
[['announcement', true], ['tutorial', true]]
|
||||
);
|
||||
});
|
||||
|
||||
test('getContentUpdateSnapshot falls back to cached snapshot when the live request fails', async () => {
|
||||
const cachedSnapshot = {
|
||||
status: 'update-available',
|
||||
promptVersion: 'announcement:2026-04-20T00:00:00Z',
|
||||
hasVisibleUpdates: true,
|
||||
latestUpdatedAt: '2026-04-20T00:00:00Z',
|
||||
latestUpdatedAtDisplay: '2026-04-20 08:00',
|
||||
items: [
|
||||
{
|
||||
slug: 'announcement',
|
||||
title: '站点公告',
|
||||
isEnabled: true,
|
||||
hasContent: true,
|
||||
isVisible: true,
|
||||
updatedAt: '2026-04-20T00:00:00Z',
|
||||
updatedAtDisplay: '2026-04-20 08:00',
|
||||
},
|
||||
],
|
||||
portalUrl: 'https://apikey.qzz.io',
|
||||
apiUrl: 'https://apikey.qzz.io/api/content-summary',
|
||||
checkedAt: Date.now() - 1000,
|
||||
};
|
||||
|
||||
const { api, getFetchCalls } = createContributionContentService({
|
||||
cachedSnapshot,
|
||||
fetchImpl: async () => {
|
||||
throw new Error('offline');
|
||||
},
|
||||
});
|
||||
|
||||
const snapshot = await api.getContentUpdateSnapshot();
|
||||
|
||||
assert.equal(getFetchCalls(), 1);
|
||||
assert.equal(snapshot.fromCache, true);
|
||||
assert.equal(snapshot.promptVersion, cachedSnapshot.promptVersion);
|
||||
assert.equal(snapshot.errorMessage, 'offline');
|
||||
assert.equal(snapshot.items[0].slug, 'announcement');
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/mail-163.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('findMailItems falls back to visible aria-label mail rows when legacy selector is missing', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('isVisibleNode'),
|
||||
extractFunction('isLikelyMailItemNode'),
|
||||
extractFunction('findMailItems'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const mailRow = {
|
||||
hidden: false,
|
||||
textContent: 'Your temporary ChatGPT verification code 911113',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'Your temporary ChatGPT verification code 911113 发件人 OpenAI';
|
||||
return '';
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 600, height: 48 };
|
||||
},
|
||||
matches() {
|
||||
return false;
|
||||
},
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'div[sign="letter"]') return [];
|
||||
if (selector === '[role="option"][aria-label]') return [mailRow];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const window = {
|
||||
getComputedStyle() {
|
||||
return { display: 'block', visibility: 'visible' };
|
||||
},
|
||||
};
|
||||
|
||||
${bundle}
|
||||
|
||||
return { findMailItems };
|
||||
`)();
|
||||
|
||||
const rows = api.findMailItems();
|
||||
assert.equal(rows.length, 1);
|
||||
});
|
||||
|
||||
test('getMailTimestamp parses visible hh:mm text even when no title attribute exists', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('parseMail163Timestamp'),
|
||||
extractFunction('isLikelyMailTimestampText'),
|
||||
extractFunction('collectMailTimestampCandidates'),
|
||||
extractFunction('getMailTimestamp'),
|
||||
].join('\n');
|
||||
|
||||
const timestamp = new Function(`
|
||||
const item = {
|
||||
getAttribute() {
|
||||
return '';
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.e00, [title], [aria-label], time, [class*="time"], [class*="date"]') {
|
||||
return [];
|
||||
}
|
||||
if (selector === 'span, div, td, strong, b') {
|
||||
return [
|
||||
{
|
||||
textContent: '22:22',
|
||||
getAttribute() {
|
||||
return '';
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
${bundle}
|
||||
|
||||
return getMailTimestamp(item);
|
||||
`)();
|
||||
|
||||
const now = new Date();
|
||||
const expected = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 22, 22, 0, 0).getTime();
|
||||
assert.equal(timestamp, expected);
|
||||
});
|
||||
|
||||
test('readOpenedMailText prefers opened body content that contains the verification code', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('collectOpenedMailTextCandidates'),
|
||||
extractFunction('selectOpenedMailTextCandidate'),
|
||||
extractFunction('readOpenedMailText'),
|
||||
extractFunction('extractVerificationCode'),
|
||||
].join('\n');
|
||||
|
||||
const text = new Function(`
|
||||
const item = {
|
||||
querySelectorAll() {
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'iframe') {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{ innerText: 'Your temporary ChatGPT login code', textContent: 'Your temporary ChatGPT login code' },
|
||||
{ innerText: 'Enter this temporary verification code to continue: 214203', textContent: 'Enter this temporary verification code to continue: 214203' },
|
||||
];
|
||||
},
|
||||
body: {
|
||||
innerText: 'fallback body',
|
||||
textContent: 'fallback body',
|
||||
},
|
||||
};
|
||||
|
||||
${bundle}
|
||||
|
||||
return readOpenedMailText(item);
|
||||
`)();
|
||||
|
||||
assert.match(text, /214203/);
|
||||
});
|
||||
|
||||
test('openMailAndGetMessageText reads opened body text and returns to inbox', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('collectOpenedMailTextCandidates'),
|
||||
extractFunction('selectOpenedMailTextCandidate'),
|
||||
extractFunction('readOpenedMailText'),
|
||||
extractFunction('returnToInbox'),
|
||||
extractFunction('openMailAndGetMessageText'),
|
||||
extractFunction('extractVerificationCode'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let inInbox = true;
|
||||
let clickCount = 0;
|
||||
const mailItem = {
|
||||
click() {
|
||||
clickCount += 1;
|
||||
inInbox = false;
|
||||
},
|
||||
};
|
||||
const inboxLink = {
|
||||
click() {
|
||||
inInbox = true;
|
||||
},
|
||||
};
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
if (selector === '.nui-tree-item-text[title="收件箱"], [title="收件箱"]') return inboxLink;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'iframe') {
|
||||
return [];
|
||||
}
|
||||
return inInbox ? [] : [{ innerText: 'Enter this temporary verification code to continue: 214203', textContent: 'Enter this temporary verification code to continue: 214203' }];
|
||||
},
|
||||
body: {
|
||||
innerText: '',
|
||||
textContent: '',
|
||||
},
|
||||
};
|
||||
|
||||
function findMailItems() {
|
||||
return inInbox ? [mailItem] : [];
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
openMailAndGetMessageText,
|
||||
getClickCount: () => clickCount,
|
||||
isInInbox: () => inInbox,
|
||||
mailItem,
|
||||
};
|
||||
`)();
|
||||
|
||||
const text = await api.openMailAndGetMessageText(api.mailItem);
|
||||
assert.match(text, /214203/);
|
||||
assert.equal(api.getClickCount(), 1);
|
||||
assert.equal(api.isInInbox(), true);
|
||||
});
|
||||
|
||||
test('openMailAndGetMessageText ignores stale pre-open text that contains an old code', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('collectOpenedMailTextCandidates'),
|
||||
extractFunction('selectOpenedMailTextCandidate'),
|
||||
extractFunction('readOpenedMailText'),
|
||||
extractFunction('returnToInbox'),
|
||||
extractFunction('openMailAndGetMessageText'),
|
||||
extractFunction('extractVerificationCode'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let stage = 'before';
|
||||
const oldText = 'OpenAI Your temporary ChatGPT login code. Ignore this old code 111111. '.repeat(10);
|
||||
const newText = 'OpenAI Your temporary ChatGPT login code. Your new code is 222222.';
|
||||
const mailItem = {
|
||||
click() {
|
||||
stage = 'after';
|
||||
},
|
||||
};
|
||||
const inboxLink = {
|
||||
click() {
|
||||
stage = 'done';
|
||||
},
|
||||
};
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
if (selector === '.nui-tree-item-text[title="收件箱"], [title="收件箱"]') return inboxLink;
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'iframe') {
|
||||
return [];
|
||||
}
|
||||
if (stage === 'before') {
|
||||
return [{ innerText: oldText, textContent: oldText }];
|
||||
}
|
||||
if (stage === 'after') {
|
||||
return [
|
||||
{ innerText: oldText, textContent: oldText },
|
||||
{ innerText: newText, textContent: newText },
|
||||
];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
body: {
|
||||
innerText: '',
|
||||
textContent: '',
|
||||
},
|
||||
};
|
||||
|
||||
function findMailItems() {
|
||||
return stage === 'done' ? [mailItem] : [];
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { openMailAndGetMessageText, mailItem };
|
||||
`)();
|
||||
|
||||
const text = await api.openMailAndGetMessageText(api.mailItem);
|
||||
assert.match(text, /222222/);
|
||||
assert.doesNotMatch(text, /111111/);
|
||||
});
|
||||
|
||||
test('handlePollEmail ignores same-minute old snapshot mail before fallback', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let currentItems = [{ id: 'old-mail' }];
|
||||
const seenCodes = new Set();
|
||||
|
||||
function findMailItems() {
|
||||
return currentItems;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
return new Set((items.length ? items : currentItems).map((item) => item.id));
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 22, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT verification code';
|
||||
}
|
||||
|
||||
function getMailRowText() {
|
||||
return 'Your temporary ChatGPT verification code 911113 发件人 OpenAI';
|
||||
}
|
||||
|
||||
function extractVerificationCode() {
|
||||
return '911113';
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail };
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.handlePollEmail(4, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: new Date(2026, 3, 22, 22, 22, 40, 0).getTime(),
|
||||
}),
|
||||
/未在 163 邮箱中找到新的匹配邮件/
|
||||
);
|
||||
});
|
||||
|
||||
test('handlePollEmail accepts a new same-minute mail that appears after the snapshot', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const oldMail = {
|
||||
id: 'old-mail',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'Old verification mail 111111 发件人 OpenAI';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const newMail = {
|
||||
id: 'new-mail',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'Your temporary ChatGPT verification code 654321 发件人 OpenAI';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
let refreshCount = 0;
|
||||
let currentItems = [oldMail];
|
||||
const seenCodes = new Set();
|
||||
|
||||
function findMailItems() {
|
||||
return currentItems;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
return new Set((items.length ? items : currentItems).map((item) => item.id));
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 22, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
function getMailSubjectText(item) {
|
||||
return item.id === 'new-mail' ? 'Your temporary ChatGPT verification code' : 'Old verification mail';
|
||||
}
|
||||
|
||||
function getMailRowText(item) {
|
||||
return item.id === 'new-mail'
|
||||
? 'Your temporary ChatGPT verification code 654321 发件人 OpenAI'
|
||||
: 'Old verification mail 111111 发件人 OpenAI';
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {
|
||||
refreshCount += 1;
|
||||
if (refreshCount >= 1) {
|
||||
currentItems = [oldMail, newMail];
|
||||
}
|
||||
}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail };
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(4, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 2,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: new Date(2026, 3, 22, 22, 22, 40, 0).getTime(),
|
||||
});
|
||||
|
||||
assert.equal(result.code, '654321');
|
||||
assert.equal(result.mailId, 'new-mail');
|
||||
});
|
||||
|
||||
test('handlePollEmail falls back to row text when the subject node is missing', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const matchingMail = {
|
||||
id: 'mail-1',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'OpenAI Your temporary ChatGPT verification code 123456';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const seenCodes = new Set();
|
||||
|
||||
function findMailItems() {
|
||||
return [matchingMail];
|
||||
}
|
||||
|
||||
function getCurrentMailIds() {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 22, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMailSubjectText() {
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMailRowText() {
|
||||
return 'OpenAI Your temporary ChatGPT verification code 123456';
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
async function openMailAndGetMessageText() {
|
||||
return '';
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail };
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(8, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.code, '123456');
|
||||
assert.equal(result.mailId, 'mail-1');
|
||||
});
|
||||
|
||||
test('handlePollEmail opens matching mail body when preview has no code', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeText'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const matchingMail = {
|
||||
id: 'mail-body-1',
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-label') return 'OpenAI Your temporary ChatGPT login code';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const seenCodes = new Set();
|
||||
let openedCount = 0;
|
||||
|
||||
function findMailItems() {
|
||||
return [matchingMail];
|
||||
}
|
||||
|
||||
function getCurrentMailIds() {
|
||||
return new Set();
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getMailTimestamp() {
|
||||
return new Date(2026, 3, 22, 22, 49, 0, 0).getTime();
|
||||
}
|
||||
|
||||
function getMailSenderText() {
|
||||
return 'OpenAI';
|
||||
}
|
||||
|
||||
function getMailSubjectText() {
|
||||
return 'Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function getMailRowText() {
|
||||
return 'OpenAI Your temporary ChatGPT login code';
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function waitForElement() {
|
||||
return { click() {} };
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
async function sleep() {}
|
||||
function log() {}
|
||||
function persistSeenCodes() {}
|
||||
function scheduleEmailCleanup() {}
|
||||
async function openMailAndGetMessageText() {
|
||||
openedCount += 1;
|
||||
return 'Enter this temporary verification code to continue: 214203';
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return { handlePollEmail, getOpenedCount: () => openedCount };
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(8, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification', 'login'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: 0,
|
||||
});
|
||||
|
||||
assert.equal(result.code, '214203');
|
||||
assert.equal(result.mailId, 'mail-body-1');
|
||||
assert.equal(api.getOpenedCount(), 1);
|
||||
});
|
||||
@@ -4,6 +4,77 @@ const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/mail-2925.js', 'utf8');
|
||||
|
||||
test('ensureMail2925Session waits at most 40 seconds for mailbox after clicking login', () => {
|
||||
assert.match(source, /waitForMail2925View\('mailbox',\s*40000\)/);
|
||||
});
|
||||
|
||||
test('ensureMail2925Session waits 1 second after filling credentials before clicking login', () => {
|
||||
assert.match(source, /fillInput\(passwordInput,\s*password\);[\s\S]*?await sleep\(200\);[\s\S]*?await sleep\(1000\);[\s\S]*?simulateClick\(loginButton\);/);
|
||||
});
|
||||
|
||||
test('detectMail2925ViewState treats top mailbox email as mailbox view', () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeNodeText'),
|
||||
extractFunction('isVisibleNode'),
|
||||
extractFunction('isMailItemNode'),
|
||||
extractFunction('resolveActionTarget'),
|
||||
extractFunction('findMailItems'),
|
||||
extractFunction('extractEmails'),
|
||||
extractFunction('getMail2925DisplayedMailboxEmail'),
|
||||
extractFunction('detectMail2925ViewState'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const MAIL_ITEM_SELECTORS = ['.mail-item'];
|
||||
const MAIL_ITEM_SELECTOR_GROUP = '.mail-item';
|
||||
const MAIL2925_REMEMBER_LOGIN_PATTERNS = [];
|
||||
const MAIL2925_AGREEMENT_PATTERNS = [];
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === '.mail-item') return [];
|
||||
if (selector === 'body *') return [headerEmail, wrongHeader];
|
||||
if (selector === '.right-header' || selector.includes('right-header')) return [headerEmail];
|
||||
if (selector.includes('[class*="user"]')) return [];
|
||||
return [];
|
||||
},
|
||||
body: {
|
||||
innerText: 'QLHazycoder qlhazycoder@2925.com tm1.openai.com@foo.example',
|
||||
textContent: 'QLHazycoder qlhazycoder@2925.com tm1.openai.com@foo.example',
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
innerHeight: 900,
|
||||
getComputedStyle() {
|
||||
return { display: 'block', visibility: 'visible' };
|
||||
},
|
||||
};
|
||||
const headerEmail = {
|
||||
hidden: false,
|
||||
textContent: 'qlhazycoder@2925.com',
|
||||
innerText: 'qlhazycoder@2925.com',
|
||||
getBoundingClientRect() { return { top: 40, left: 400, width: 120, height: 20 }; },
|
||||
closest() { return null; },
|
||||
};
|
||||
const wrongHeader = {
|
||||
hidden: false,
|
||||
textContent: 'tm1.openai.com@foo.example',
|
||||
innerText: 'tm1.openai.com@foo.example',
|
||||
getBoundingClientRect() { return { top: 48, left: 430, width: 150, height: 20 }; },
|
||||
closest() { return null; },
|
||||
};
|
||||
function detectMail2925LimitMessage() { return ''; }
|
||||
function findMail2925LoginPasswordInput() { return null; }
|
||||
function findMail2925LoginEmailInput() { return null; }
|
||||
function getPageTextSample() { return 'qlhazycoder@2925.com'; }
|
||||
${bundle}
|
||||
return { detectMail2925ViewState };
|
||||
`)();
|
||||
|
||||
const state = api.detectMail2925ViewState();
|
||||
assert.equal(state.view, 'mailbox');
|
||||
assert.equal(state.mailboxEmail, 'qlhazycoder@2925.com');
|
||||
});
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
@@ -52,7 +123,10 @@ function extractFunction(name) {
|
||||
}
|
||||
|
||||
test('handlePollEmail establishes a baseline after opening from detail view and only picks mail from a later refresh', async () => {
|
||||
const bundle = extractFunction('handlePollEmail');
|
||||
const bundle = [
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let state = 'detail';
|
||||
@@ -60,13 +134,22 @@ let refreshCalls = 0;
|
||||
const clickOrder = [];
|
||||
const readAndDeleteCalls = [];
|
||||
const seenCodes = new Set();
|
||||
const deletedMailIds = new Set();
|
||||
const baselineMail = { id: 'baseline', text: 'OpenAI newsletter without code' };
|
||||
const newMail = { id: 'new', text: 'OpenAI verification code 654321' };
|
||||
|
||||
function findMailItems() {
|
||||
if (state === 'detail') return [];
|
||||
if (state === 'baseline') return [baselineMail];
|
||||
return [baselineMail, newMail];
|
||||
const items = [];
|
||||
if (state === 'detail') return items;
|
||||
if (state === 'baseline' || state === 'with-new') {
|
||||
if (!deletedMailIds.has('baseline')) {
|
||||
items.push(baselineMail);
|
||||
}
|
||||
}
|
||||
if (state === 'with-new' && !deletedMailIds.has('new')) {
|
||||
items.push(newMail);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
@@ -99,7 +182,9 @@ async function sleepRandom() {}
|
||||
|
||||
async function returnToInbox() {
|
||||
clickOrder.push('inbox');
|
||||
if (state === 'detail') {
|
||||
state = 'baseline';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -113,6 +198,7 @@ async function refreshInbox() {
|
||||
|
||||
async function openMailAndDeleteAfterRead(item) {
|
||||
readAndDeleteCalls.push(item.id);
|
||||
deletedMailIds.add(item.id);
|
||||
return item.id === 'new' ? 'Your ChatGPT code is 654321' : 'No code here';
|
||||
}
|
||||
|
||||
@@ -142,11 +228,14 @@ return {
|
||||
|
||||
assert.equal(result.code, '654321');
|
||||
assert.deepEqual(api.getClickOrder(), ['inbox', 'refresh', 'inbox', 'refresh']);
|
||||
assert.deepEqual(api.getReadAndDeleteCalls(), ['new']);
|
||||
assert.deepEqual(api.getReadAndDeleteCalls(), ['baseline', 'new']);
|
||||
});
|
||||
|
||||
test('handlePollEmail ignores targetEmail and still tests any matching ChatGPT mail', async () => {
|
||||
const bundle = extractFunction('handlePollEmail');
|
||||
test('handlePollEmail keeps ignoring targetEmail when receive-mode matching is disabled', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let state = 'empty';
|
||||
@@ -220,12 +309,191 @@ return {
|
||||
maxAttempts: 4,
|
||||
intervalMs: 1,
|
||||
targetEmail: 'expected@example.com',
|
||||
mail2925MatchTargetEmail: false,
|
||||
});
|
||||
|
||||
assert.equal(result.code, '112233');
|
||||
assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-1']);
|
||||
});
|
||||
|
||||
test('handlePollEmail skips explicit mismatched target emails when receive-mode matching is enabled', async () => {
|
||||
const bundle = [
|
||||
extractFunction('extractEmails'),
|
||||
extractFunction('emailMatchesTarget'),
|
||||
extractFunction('getTargetEmailMatchState'),
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let state = 'ready';
|
||||
const seenCodes = new Set();
|
||||
const readAndDeleteCalls = [];
|
||||
const mismatchMail = {
|
||||
id: 'mail-1',
|
||||
text: 'ChatGPT verification code 112233 for another.user@example.com',
|
||||
};
|
||||
const targetMail = {
|
||||
id: 'mail-2',
|
||||
text: 'ChatGPT verification code 445566 for expected@example.com',
|
||||
};
|
||||
|
||||
function findMailItems() {
|
||||
return state === 'ready' ? [mismatchMail, targetMail] : [];
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
return new Set(items.map((item) => item.id));
|
||||
}
|
||||
|
||||
function parseMailItemTimestamp() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function matchesMailFilters(text) {
|
||||
return /chatgpt|openai|verification/i.test(String(text || ''));
|
||||
}
|
||||
|
||||
function getMailItemText(item) {
|
||||
return item.text;
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
async function sleepRandom() {}
|
||||
async function returnToInbox() {
|
||||
return true;
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
|
||||
async function openMailAndDeleteAfterRead(item) {
|
||||
readAndDeleteCalls.push(item.id);
|
||||
return item.text;
|
||||
}
|
||||
|
||||
async function ensureSeenCodesSession() {}
|
||||
function persistSeenCodes() {}
|
||||
function log() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
handlePollEmail,
|
||||
getReadAndDeleteCalls() {
|
||||
return readAndDeleteCalls.slice();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(8, {
|
||||
senderFilters: ['chatgpt'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
targetEmail: 'expected@example.com',
|
||||
mail2925MatchTargetEmail: true,
|
||||
});
|
||||
|
||||
assert.equal(result.code, '445566');
|
||||
assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-2']);
|
||||
});
|
||||
|
||||
test('handlePollEmail only accepts 2925 mails inside the fixed lookback window', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeMinuteTimestamp'),
|
||||
extractFunction('handlePollEmail'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let state = 'ready';
|
||||
const seenCodes = new Set();
|
||||
const readAndDeleteCalls = [];
|
||||
const oldMail = {
|
||||
id: 'mail-old',
|
||||
text: 'OpenAI verification code 111111',
|
||||
timestamp: 1000,
|
||||
};
|
||||
const windowMail = {
|
||||
id: 'mail-window',
|
||||
text: 'OpenAI verification code 222222',
|
||||
timestamp: 301000,
|
||||
};
|
||||
|
||||
function findMailItems() {
|
||||
return state === 'ready' ? [oldMail, windowMail] : [];
|
||||
}
|
||||
|
||||
function getMailItemId(item) {
|
||||
return item.id;
|
||||
}
|
||||
|
||||
function getCurrentMailIds(items = []) {
|
||||
return new Set(items.map((item) => item.id));
|
||||
}
|
||||
|
||||
function parseMailItemTimestamp(item) {
|
||||
return item.timestamp;
|
||||
}
|
||||
|
||||
function matchesMailFilters(text) {
|
||||
return /openai|verification/i.test(String(text || ''));
|
||||
}
|
||||
|
||||
function getMailItemText(item) {
|
||||
return item.text;
|
||||
}
|
||||
|
||||
function extractVerificationCode(text) {
|
||||
const match = String(text || '').match(/(\\d{6})/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
async function sleepRandom() {}
|
||||
async function returnToInbox() {
|
||||
return true;
|
||||
}
|
||||
async function refreshInbox() {}
|
||||
|
||||
async function openMailAndDeleteAfterRead(item) {
|
||||
readAndDeleteCalls.push(item.id);
|
||||
return item.text;
|
||||
}
|
||||
|
||||
async function ensureSeenCodesSession() {}
|
||||
function persistSeenCodes() {}
|
||||
function log() {}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
handlePollEmail,
|
||||
getReadAndDeleteCalls() {
|
||||
return readAndDeleteCalls.slice();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.handlePollEmail(4, {
|
||||
senderFilters: ['openai'],
|
||||
subjectFilters: ['verification'],
|
||||
maxAttempts: 1,
|
||||
intervalMs: 1,
|
||||
filterAfterTimestamp: 120000,
|
||||
});
|
||||
|
||||
assert.equal(result.code, '222222');
|
||||
assert.deepEqual(api.getReadAndDeleteCalls(), ['mail-window']);
|
||||
});
|
||||
|
||||
test('ensureSeenCodesSession resets tried codes only when a new verification step session starts', async () => {
|
||||
const bundle = [
|
||||
extractFunction('buildSeenCodeSessionKey'),
|
||||
@@ -419,12 +687,17 @@ test('deleteAllMailboxEmails selects all messages and clicks delete', async () =
|
||||
const calls = [];
|
||||
const selectAllControl = { kind: 'select-all' };
|
||||
const deleteButton = { kind: 'delete' };
|
||||
let mailboxCleared = false;
|
||||
|
||||
async function returnToInbox() {
|
||||
calls.push('inbox');
|
||||
return true;
|
||||
}
|
||||
|
||||
function findMailItems() {
|
||||
return mailboxCleared ? [] : [{ id: 'mail-1' }];
|
||||
}
|
||||
|
||||
function findSelectAllControl() {
|
||||
return selectAllControl;
|
||||
}
|
||||
@@ -444,11 +717,13 @@ function simulateClick(node) {
|
||||
}
|
||||
if (node === deleteButton) {
|
||||
calls.push('delete');
|
||||
mailboxCleared = true;
|
||||
return;
|
||||
}
|
||||
throw new Error('unexpected node');
|
||||
}
|
||||
|
||||
async function sleep() {}
|
||||
async function sleepRandom() {}
|
||||
|
||||
const console = { warn() {} };
|
||||
@@ -469,3 +744,187 @@ return {
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(api.getCalls(), ['inbox', 'select-all', 'delete']);
|
||||
});
|
||||
|
||||
test('findAgreementCheckbox skips 30-day login checkbox and picks agreement checkbox', async () => {
|
||||
const bundle = [
|
||||
extractFunction('normalizeNodeText'),
|
||||
extractFunction('isVisibleNode'),
|
||||
extractFunction('resolveActionTarget'),
|
||||
extractFunction('findAgreementContainer'),
|
||||
extractFunction('isAgreementText'),
|
||||
extractFunction('getCheckboxContextText'),
|
||||
extractFunction('findAgreementCheckbox'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const MAIL2925_REMEMBER_LOGIN_PATTERNS = [
|
||||
/30天内免登录/,
|
||||
/免登录/,
|
||||
/记住登录/,
|
||||
/保持登录/,
|
||||
];
|
||||
const MAIL2925_AGREEMENT_PATTERNS = [
|
||||
/我已阅读并同意/,
|
||||
/服务协议/,
|
||||
/隐私政策/,
|
||||
];
|
||||
|
||||
const rememberCheckbox = {
|
||||
kind: 'remember-checkbox',
|
||||
disabled: false,
|
||||
readOnly: false,
|
||||
hidden: false,
|
||||
classList: { contains() { return false; } },
|
||||
getBoundingClientRect() { return { width: 14, height: 14 }; },
|
||||
closest(selector) {
|
||||
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
|
||||
if (selector === 'label') return rememberLabel;
|
||||
if (selector === 'label, div, span, p, li, form') return rememberLabel;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const agreementCheckbox = {
|
||||
kind: 'agreement-checkbox',
|
||||
disabled: false,
|
||||
readOnly: false,
|
||||
hidden: false,
|
||||
classList: { contains() { return false; } },
|
||||
getBoundingClientRect() { return { width: 14, height: 14 }; },
|
||||
closest(selector) {
|
||||
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
|
||||
if (selector === 'label') return agreementLabel;
|
||||
if (selector === 'label, div, span, p, li, form') return agreementLabel;
|
||||
return null;
|
||||
},
|
||||
};
|
||||
const rememberLabel = {
|
||||
innerText: '30天内免登录',
|
||||
textContent: '30天内免登录',
|
||||
hidden: false,
|
||||
getBoundingClientRect() { return { width: 100, height: 20 }; },
|
||||
parentElement: null,
|
||||
};
|
||||
const agreementLabel = {
|
||||
innerText: '我已阅读并同意 《服务协议》 和 《隐私政策》',
|
||||
textContent: '我已阅读并同意 《服务协议》 和 《隐私政策》',
|
||||
hidden: false,
|
||||
getBoundingClientRect() { return { width: 220, height: 24 }; },
|
||||
parentElement: null,
|
||||
querySelector(selector) {
|
||||
return selector.includes('checkbox') ? agreementCheckbox : null;
|
||||
},
|
||||
};
|
||||
rememberCheckbox.parentElement = rememberLabel;
|
||||
agreementCheckbox.parentElement = agreementLabel;
|
||||
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'label, div, span, p, form') {
|
||||
return [rememberLabel, agreementLabel];
|
||||
}
|
||||
if (selector === 'input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox') {
|
||||
return [rememberCheckbox, agreementCheckbox];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const window = {
|
||||
getComputedStyle() {
|
||||
return { display: 'block', visibility: 'visible' };
|
||||
},
|
||||
};
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
findAgreementCheckbox,
|
||||
rememberCheckbox,
|
||||
agreementCheckbox,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.findAgreementCheckbox(), api.agreementCheckbox);
|
||||
});
|
||||
|
||||
test('ensureAgreementChecked clicks all visible login checkboxes', async () => {
|
||||
const bundle = [
|
||||
extractFunction('isVisibleNode'),
|
||||
extractFunction('resolveActionTarget'),
|
||||
extractFunction('isCheckboxChecked'),
|
||||
extractFunction('ensureAgreementChecked'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const rememberCheckbox = {
|
||||
disabled: false,
|
||||
readOnly: false,
|
||||
hidden: false,
|
||||
checked: false,
|
||||
classList: { contains() { return false; } },
|
||||
getBoundingClientRect() { return { width: 14, height: 14 }; },
|
||||
click() { this.checked = true; },
|
||||
closest(selector) {
|
||||
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
|
||||
return null;
|
||||
},
|
||||
querySelector() { return null; },
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-checked') return this.checked ? 'true' : 'false';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
const agreementCheckbox = {
|
||||
disabled: false,
|
||||
readOnly: false,
|
||||
hidden: false,
|
||||
checked: false,
|
||||
classList: { contains() { return false; } },
|
||||
getBoundingClientRect() { return { width: 14, height: 14 }; },
|
||||
click() { this.checked = true; },
|
||||
closest(selector) {
|
||||
if (selector === 'button, [role="button"], a, label, .el-checkbox, .el-checkbox__input') return this;
|
||||
return null;
|
||||
},
|
||||
querySelector() { return null; },
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-checked') return this.checked ? 'true' : 'false';
|
||||
return '';
|
||||
},
|
||||
};
|
||||
|
||||
const document = {
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[type="checkbox"], [role="checkbox"], .ivu-checkbox, .el-checkbox') {
|
||||
return [rememberCheckbox, agreementCheckbox];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const window = {
|
||||
getComputedStyle() {
|
||||
return { display: 'block', visibility: 'visible' };
|
||||
},
|
||||
};
|
||||
|
||||
async function sleep() {}
|
||||
function simulateClick(node) {
|
||||
node.click();
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
rememberCheckbox,
|
||||
agreementCheckbox,
|
||||
ensureAgreementChecked,
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.ensureAgreementChecked();
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(api.rememberCheckbox.checked, true);
|
||||
assert.equal(api.agreementCheckbox.checked, true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const utils = require('../mail2925-utils.js');
|
||||
|
||||
test('normalizeMail2925Account normalizes key fields', () => {
|
||||
const account = utils.normalizeMail2925Account({
|
||||
id: 'a-1',
|
||||
email: ' Demo@2925.com ',
|
||||
password: 'secret',
|
||||
enabled: 0,
|
||||
lastUsedAt: '123',
|
||||
disabledUntil: '456',
|
||||
});
|
||||
|
||||
assert.deepStrictEqual(account, {
|
||||
id: 'a-1',
|
||||
email: 'demo@2925.com',
|
||||
password: 'secret',
|
||||
enabled: false,
|
||||
lastUsedAt: 123,
|
||||
lastLoginAt: 0,
|
||||
lastLimitAt: 0,
|
||||
disabledUntil: 456,
|
||||
lastError: '',
|
||||
});
|
||||
});
|
||||
|
||||
test('pickMail2925AccountForRun skips cooldown and picks the least recently used account', () => {
|
||||
const now = 1000;
|
||||
const picked = utils.pickMail2925AccountForRun([
|
||||
{ id: 'cooldown', email: 'cool@2925.com', password: 'x', disabledUntil: now + 10, lastUsedAt: 1 },
|
||||
{ id: 'b', email: 'b@2925.com', password: 'x', lastUsedAt: 50 },
|
||||
{ id: 'a', email: 'a@2925.com', password: 'x', lastUsedAt: 10 },
|
||||
], { now });
|
||||
|
||||
assert.equal(picked.id, 'a');
|
||||
});
|
||||
|
||||
test('getMail2925AccountStatus reports cooldown before error', () => {
|
||||
const status = utils.getMail2925AccountStatus({
|
||||
email: 'demo@2925.com',
|
||||
password: 'secret',
|
||||
enabled: true,
|
||||
disabledUntil: Date.now() + 60_000,
|
||||
lastError: '子邮箱已达上限邮箱',
|
||||
});
|
||||
|
||||
assert.equal(status, 'cooldown');
|
||||
});
|
||||
|
||||
test('parseMail2925ImportText parses 邮箱----密码 rows', () => {
|
||||
const parsed = utils.parseMail2925ImportText(`
|
||||
邮箱----密码
|
||||
demo1@2925.com----pass1
|
||||
demo2@2925.com----pass2
|
||||
`);
|
||||
|
||||
assert.deepStrictEqual(parsed, [
|
||||
{ email: 'demo1@2925.com', password: 'pass1' },
|
||||
{ email: 'demo2@2925.com', password: 'pass2' },
|
||||
]);
|
||||
});
|
||||
@@ -26,3 +26,12 @@ test('managed alias utils validate provider email with or without configured bas
|
||||
assert.equal(api.isManagedAliasEmail('manual@gmail.com', 'gmail', ''), true);
|
||||
assert.equal(api.isManagedAliasEmail('manual@qq.com', 'gmail', ''), false);
|
||||
});
|
||||
|
||||
test('managed alias utils keep 2925 alias generation behind provide mode only', () => {
|
||||
assert.equal(api.normalizeMail2925Mode('provide'), 'provide');
|
||||
assert.equal(api.normalizeMail2925Mode('receive'), 'receive');
|
||||
assert.equal(api.normalizeMail2925Mode('other'), 'provide');
|
||||
assert.equal(api.usesManagedAliasGeneration('gmail'), true);
|
||||
assert.equal(api.usesManagedAliasGeneration('2925', { mail2925Mode: 'provide' }), true);
|
||||
assert.equal(api.usesManagedAliasGeneration('2925', { mail2925Mode: 'receive' }), false);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function extractFunction(source, 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('sidepanel run count input no longer hardcodes max=50', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const inputTag = html.match(/<input type="number" id="input-run-count"[^>]+>/);
|
||||
|
||||
assert.ok(inputTag, 'run count input should exist');
|
||||
assert.doesNotMatch(inputTag[0], /\smax="50"/);
|
||||
});
|
||||
|
||||
test('sidepanel getRunCountValue no longer clamps run count to 50', () => {
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
const bundle = extractFunction(source, 'getRunCountValue');
|
||||
|
||||
const api = new Function(`
|
||||
const inputRunCount = { value: '88' };
|
||||
${bundle}
|
||||
return {
|
||||
getRunCountValue,
|
||||
setValue(value) {
|
||||
inputRunCount.value = value;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.getRunCountValue(), 88);
|
||||
api.setValue('0');
|
||||
assert.equal(api.getRunCountValue(), 1);
|
||||
});
|
||||
|
||||
test('background normalizeRunCount no longer clamps values to 50', () => {
|
||||
const source = fs.readFileSync('background.js', 'utf8');
|
||||
const bundle = extractFunction(source, 'normalizeRunCount');
|
||||
|
||||
const api = new Function(`
|
||||
${bundle}
|
||||
return { normalizeRunCount };
|
||||
`)();
|
||||
|
||||
assert.equal(api.normalizeRunCount(88), 88);
|
||||
assert.equal(api.normalizeRunCount('120'), 120);
|
||||
assert.equal(api.normalizeRunCount(0), 1);
|
||||
assert.equal(api.normalizeRunCount('bad'), 1);
|
||||
});
|
||||
@@ -207,7 +207,7 @@ test('account records manager supports filter chips and partial multi-select del
|
||||
finalStatus: 'stopped',
|
||||
finishedAt: '2026-04-17T04:28:00.000Z',
|
||||
retryCount: 1,
|
||||
failureLabel: '流程已停止',
|
||||
failureLabel: '步骤 7 停止',
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -303,6 +303,7 @@ test('account records manager supports filter chips and partial multi-select del
|
||||
assert.doesNotMatch(list.innerHTML, /success@example\.com/);
|
||||
assert.match(list.innerHTML, /failed@example\.com/);
|
||||
assert.match(list.innerHTML, /stopped@example\.com/);
|
||||
assert.match(list.innerHTML, /步骤 7 停止/);
|
||||
|
||||
btnToggleAccountRecordsSelection.listeners.click();
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => sidepanelSource.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 < sidepanelSource.length; i += 1) {
|
||||
const ch = sidepanelSource[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < sidepanelSource.length; end += 1) {
|
||||
const ch = sidepanelSource[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
function createApi({ refreshImpl } = {}) {
|
||||
const bundle = extractFunction('startAutoRunFromCurrentSettings');
|
||||
|
||||
return new Function(`
|
||||
const events = [];
|
||||
const latestState = { contributionMode: false };
|
||||
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 };
|
||||
const chrome = {
|
||||
runtime: {
|
||||
async sendMessage(message) {
|
||||
events.push({ type: 'send', message });
|
||||
return { ok: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
const console = {
|
||||
warn(...args) {
|
||||
events.push({ type: 'warn', args });
|
||||
},
|
||||
};
|
||||
function getRunCountValue() { return 3; }
|
||||
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' });
|
||||
${refreshImpl ? 'return (' + refreshImpl + ')();' : 'return null;'}
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
startAutoRunFromCurrentSettings,
|
||||
getEvents() {
|
||||
return events;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
}
|
||||
|
||||
test('startAutoRunFromCurrentSettings refreshes contribution content hint before starting auto run', async () => {
|
||||
const api = createApi();
|
||||
|
||||
const result = await api.startAutoRunFromCurrentSettings();
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(
|
||||
api.getEvents().map((entry) => entry.type),
|
||||
['refresh', 'send']
|
||||
);
|
||||
assert.equal(api.getEvents()[1].message.type, 'AUTO_RUN');
|
||||
});
|
||||
|
||||
test('startAutoRunFromCurrentSettings continues auto run when contribution content refresh fails', async () => {
|
||||
const api = createApi({
|
||||
refreshImpl: 'async () => { throw new Error("refresh failed"); }',
|
||||
});
|
||||
|
||||
const result = await api.startAutoRunFromCurrentSettings();
|
||||
const events = api.getEvents();
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(
|
||||
events.map((entry) => entry.type),
|
||||
['refresh', 'warn', 'send']
|
||||
);
|
||||
assert.match(String(events[1].args[0]), /Failed to refresh contribution content hint before auto run/);
|
||||
assert.equal(events[2].message.type, 'AUTO_RUN');
|
||||
});
|
||||
@@ -0,0 +1,96 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.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('auto-run fallback risk warning starts at 3 runs', () => {
|
||||
const bundle = extractFunction('shouldWarnAutoRunFallbackRisk');
|
||||
|
||||
const api = new Function(`
|
||||
const AUTO_RUN_FALLBACK_RISK_WARNING_MIN_RUNS = 3;
|
||||
${bundle}
|
||||
return { shouldWarnAutoRunFallbackRisk };
|
||||
`)();
|
||||
|
||||
assert.equal(api.shouldWarnAutoRunFallbackRisk(2, false), false);
|
||||
assert.equal(api.shouldWarnAutoRunFallbackRisk(3, false), true);
|
||||
assert.equal(api.shouldWarnAutoRunFallbackRisk(10, true), true);
|
||||
});
|
||||
|
||||
test('auto-run fallback risk modal uses the single-node warning copy', async () => {
|
||||
const bundle = extractFunction('openAutoRunFallbackRiskConfirmModal');
|
||||
|
||||
const api = new Function(`
|
||||
let capturedOptions = null;
|
||||
async function openConfirmModalWithOption(options) {
|
||||
capturedOptions = options;
|
||||
return { confirmed: true, optionChecked: false };
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
openAutoRunFallbackRiskConfirmModal,
|
||||
getCapturedOptions() {
|
||||
return capturedOptions;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.openAutoRunFallbackRiskConfirmModal(3);
|
||||
const options = api.getCapturedOptions();
|
||||
|
||||
assert.deepStrictEqual(result, { confirmed: true, dismissPrompt: false });
|
||||
assert.equal(options.title, '自动运行风险提醒');
|
||||
assert.equal(
|
||||
options.message,
|
||||
'当前轮数已经不适合单节点情况,请确保已经配置并打开节点轮询功能(若没有配置,请点击贡献/使用按钮,根据网页中使用教程进行配置),避免连续使用一个节点注册,导致出现手机号验证。'
|
||||
);
|
||||
assert.equal(options.confirmLabel, '继续');
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.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;
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function createRow(initialDisplay = 'none') {
|
||||
return {
|
||||
style: { display: initialDisplay },
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel html places cloudflare temp email controls in a standalone section', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
assert.match(html, /id="cloudflare-temp-email-section"/);
|
||||
assert.match(html, /id="btn-cloudflare-temp-email-usage-guide"/);
|
||||
assert.match(html, /id="btn-cloudflare-temp-email-github"/);
|
||||
assert.match(html, /id="row-temp-email-random-subdomain-toggle"/);
|
||||
assert.match(html, /id="input-temp-email-use-random-subdomain"/);
|
||||
assert.doesNotMatch(html, /id="row-temp-email-random-subdomain-domain"/);
|
||||
});
|
||||
|
||||
test('sidepanel modal message preserves line breaks and supports inline links', () => {
|
||||
const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
|
||||
assert.match(css, /\.modal-message\s*\{[\s\S]*white-space:\s*pre-line;/);
|
||||
assert.match(css, /\.modal-message a,\s*[\s\S]*\.modal-alert a/);
|
||||
});
|
||||
|
||||
test('buildCloudflareTempEmailUsageGuideModalConfig returns a modal payload with inline links for generator mode', () => {
|
||||
const bundle = extractFunction('buildCloudflareTempEmailUsageGuideModalConfig');
|
||||
|
||||
const api = new Function(`
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'cloudflare-temp-email' };
|
||||
const CLOUDFLARE_TEMP_EMAIL_BUILD_TUTORIAL_URL = 'https://linux.do/t/topic/316819';
|
||||
const CLOUDFLARE_TEMP_EMAIL_RANDOM_SUBDOMAIN_ISSUE_URL = 'https://github.com/dreamhunter2333/cloudflare_temp_email/issues/942';
|
||||
function getSelectedEmailGenerator() { return String(selectEmailGenerator.value || '').trim().toLowerCase(); }
|
||||
${bundle}
|
||||
return {
|
||||
buildCloudflareTempEmailUsageGuideModalConfig,
|
||||
};
|
||||
`)();
|
||||
|
||||
const modalConfig = api.buildCloudflareTempEmailUsageGuideModalConfig();
|
||||
assert.equal(typeof modalConfig.title, 'string');
|
||||
assert.equal(typeof modalConfig.messageHtml, 'string');
|
||||
assert.equal(typeof modalConfig.alert?.text, 'string');
|
||||
assert.equal(modalConfig.title.length > 0, true);
|
||||
assert.equal(modalConfig.messageHtml.length > 0, true);
|
||||
assert.equal(modalConfig.alert.text.length > 0, true);
|
||||
assert.equal(modalConfig.messageHtml.includes('<a '), true);
|
||||
assert.equal(modalConfig.messageHtml.includes('Issue #942'), true);
|
||||
assert.equal(modalConfig.messageHtml.includes('LINUX DO 教程'), true);
|
||||
});
|
||||
|
||||
test('buildCloudflareTempEmailUsageGuideModalConfig returns a distinct alert for provider mode', () => {
|
||||
const bundle = extractFunction('buildCloudflareTempEmailUsageGuideModalConfig');
|
||||
|
||||
const api = new Function(`
|
||||
const selectMailProvider = { value: 'cloudflare-temp-email' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
const CLOUDFLARE_TEMP_EMAIL_BUILD_TUTORIAL_URL = 'https://linux.do/t/topic/316819';
|
||||
const CLOUDFLARE_TEMP_EMAIL_RANDOM_SUBDOMAIN_ISSUE_URL = 'https://github.com/dreamhunter2333/cloudflare_temp_email/issues/942';
|
||||
function getSelectedEmailGenerator() { return String(selectEmailGenerator.value || '').trim().toLowerCase(); }
|
||||
${bundle}
|
||||
return {
|
||||
buildCloudflareTempEmailUsageGuideModalConfig,
|
||||
};
|
||||
`)();
|
||||
|
||||
const providerConfig = api.buildCloudflareTempEmailUsageGuideModalConfig();
|
||||
|
||||
const generatorApi = new Function(`
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'cloudflare-temp-email' };
|
||||
const CLOUDFLARE_TEMP_EMAIL_BUILD_TUTORIAL_URL = 'https://linux.do/t/topic/316819';
|
||||
const CLOUDFLARE_TEMP_EMAIL_RANDOM_SUBDOMAIN_ISSUE_URL = 'https://github.com/dreamhunter2333/cloudflare_temp_email/issues/942';
|
||||
function getSelectedEmailGenerator() { return String(selectEmailGenerator.value || '').trim().toLowerCase(); }
|
||||
${bundle}
|
||||
return {
|
||||
buildCloudflareTempEmailUsageGuideModalConfig,
|
||||
};
|
||||
`)();
|
||||
|
||||
const generatorConfig = generatorApi.buildCloudflareTempEmailUsageGuideModalConfig();
|
||||
assert.equal(typeof providerConfig.alert?.text, 'string');
|
||||
assert.equal(typeof providerConfig.messageHtml, 'string');
|
||||
assert.equal(providerConfig.alert.text.length > 0, true);
|
||||
assert.equal(providerConfig.messageHtml.length > 0, true);
|
||||
assert.notEqual(providerConfig.alert.text, generatorConfig.alert.text);
|
||||
});
|
||||
|
||||
test('openCloudflareTempEmailRepositoryPage opens the upstream repository', () => {
|
||||
const bundle = extractFunction('openCloudflareTempEmailRepositoryPage');
|
||||
|
||||
const api = new Function(`
|
||||
const calls = [];
|
||||
const CLOUDFLARE_TEMP_EMAIL_REPOSITORY_URL = 'https://github.com/dreamhunter2333/cloudflare_temp_email';
|
||||
function openExternalUrl(url) { calls.push(url); }
|
||||
${bundle}
|
||||
return {
|
||||
calls,
|
||||
openCloudflareTempEmailRepositoryPage,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.openCloudflareTempEmailRepositoryPage();
|
||||
assert.deepEqual(api.calls, ['https://github.com/dreamhunter2333/cloudflare_temp_email']);
|
||||
});
|
||||
|
||||
test('applyCloudflareTempEmailSettingsState restores the random subdomain toggle and temp domain list', () => {
|
||||
const bundle = extractFunction('applyCloudflareTempEmailSettingsState');
|
||||
|
||||
const api = new Function(`
|
||||
const inputTempEmailBaseUrl = { value: '' };
|
||||
const inputTempEmailAdminAuth = { value: '' };
|
||||
const inputTempEmailCustomAuth = { value: '' };
|
||||
const inputTempEmailReceiveMailbox = { value: '' };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: false };
|
||||
const calls = {
|
||||
domainOptions: [],
|
||||
domainEditMode: [],
|
||||
};
|
||||
function renderCloudflareTempEmailDomainOptions(value) { calls.domainOptions.push(value); }
|
||||
function setCloudflareTempEmailDomainEditMode(editing, options) { calls.domainEditMode.push({ editing, options }); }
|
||||
${bundle}
|
||||
return {
|
||||
applyCloudflareTempEmailSettingsState,
|
||||
calls,
|
||||
inputTempEmailBaseUrl,
|
||||
inputTempEmailAdminAuth,
|
||||
inputTempEmailCustomAuth,
|
||||
inputTempEmailReceiveMailbox,
|
||||
inputTempEmailUseRandomSubdomain,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.applyCloudflareTempEmailSettingsState({
|
||||
cloudflareTempEmailBaseUrl: 'https://temp.example.com',
|
||||
cloudflareTempEmailAdminAuth: 'admin-secret',
|
||||
cloudflareTempEmailCustomAuth: 'custom-secret',
|
||||
cloudflareTempEmailReceiveMailbox: 'relay@example.com',
|
||||
cloudflareTempEmailUseRandomSubdomain: true,
|
||||
cloudflareTempEmailDomain: 'mail.example.com',
|
||||
});
|
||||
|
||||
assert.equal(api.inputTempEmailBaseUrl.value, 'https://temp.example.com');
|
||||
assert.equal(api.inputTempEmailAdminAuth.value, 'admin-secret');
|
||||
assert.equal(api.inputTempEmailCustomAuth.value, 'custom-secret');
|
||||
assert.equal(api.inputTempEmailReceiveMailbox.value, 'relay@example.com');
|
||||
assert.equal(api.inputTempEmailUseRandomSubdomain.checked, true);
|
||||
assert.deepEqual(api.calls.domainOptions, ['mail.example.com']);
|
||||
assert.deepEqual(api.calls.domainEditMode, [{ editing: false, options: { clearInput: true } }]);
|
||||
});
|
||||
|
||||
test('updateMailProviderUI keeps the temp domain selector visible and updates the hint when random subdomain is enabled', () => {
|
||||
const bundle = extractFunction('updateMailProviderUI');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = {
|
||||
cloudflareTempEmailDomains: ['mail.example.com'],
|
||||
};
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
const ICLOUD_PROVIDER = 'icloud';
|
||||
const GMAIL_PROVIDER = 'gmail';
|
||||
const LUCKMAIL_PROVIDER = 'luckmail-api';
|
||||
const rowMail2925Mode = ${JSON.stringify(createRow('none'))};
|
||||
const rowMail2925PoolSettings = ${JSON.stringify(createRow('none'))};
|
||||
const rowEmailPrefix = ${JSON.stringify(createRow('none'))};
|
||||
const rowInbucketHost = ${JSON.stringify(createRow('none'))};
|
||||
const rowInbucketMailbox = ${JSON.stringify(createRow('none'))};
|
||||
const rowEmailGenerator = ${JSON.stringify(createRow(''))};
|
||||
const rowCfDomain = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailBaseUrl = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailAdminAuth = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailCustomAuth = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailReceiveMailbox = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailRandomSubdomainToggle = ${JSON.stringify(createRow('none'))};
|
||||
const rowTempEmailDomain = ${JSON.stringify(createRow('none'))};
|
||||
const cloudflareTempEmailSection = ${JSON.stringify(createRow('none'))};
|
||||
const hotmailSection = ${JSON.stringify(createRow('none'))};
|
||||
const mail2925Section = ${JSON.stringify(createRow('none'))};
|
||||
const luckmailSection = ${JSON.stringify(createRow('none'))};
|
||||
const icloudSection = ${JSON.stringify(createRow('none'))};
|
||||
const labelEmailPrefix = { textContent: '' };
|
||||
const inputEmailPrefix = { placeholder: '', style: { display: '' }, readOnly: false };
|
||||
const labelMail2925UseAccountPool = ${JSON.stringify(createRow('none'))};
|
||||
const selectMail2925PoolAccount = { style: { display: 'none' }, disabled: false };
|
||||
const btnFetchEmail = { hidden: false, disabled: false, textContent: '' };
|
||||
const btnMailLogin = { disabled: false, textContent: '', title: '' };
|
||||
const inputEmail = { readOnly: false, placeholder: '', value: '' };
|
||||
const autoHintText = { textContent: '' };
|
||||
const rowHotmailServiceMode = ${JSON.stringify(createRow('none'))};
|
||||
const rowHotmailRemoteBaseUrl = ${JSON.stringify(createRow('none'))};
|
||||
const rowHotmailLocalBaseUrl = ${JSON.stringify(createRow('none'))};
|
||||
const inputMail2925UseAccountPool = { checked: false };
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'cloudflare-temp-email', disabled: false };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: false };
|
||||
const calls = {
|
||||
tempDomainEditMode: [],
|
||||
};
|
||||
function isLuckmailProvider() { return false; }
|
||||
function isCustomMailProvider() { return false; }
|
||||
function isIcloudMailProvider() { return false; }
|
||||
function usesGeneratedAliasMailProvider() { return false; }
|
||||
function getSelectedMail2925Mode() { return 'provide'; }
|
||||
function getManagedAliasProviderUiCopy() { return null; }
|
||||
function getCurrentRegistrationEmailUiCopy() {
|
||||
return {
|
||||
buttonLabel: '生成 Temp',
|
||||
placeholder: '点击生成 Cloudflare Temp Email,或手动粘贴邮箱',
|
||||
label: 'Cloudflare Temp Email',
|
||||
};
|
||||
}
|
||||
function updateMailLoginButtonState() {}
|
||||
function getSelectedHotmailServiceMode() { return 'local'; }
|
||||
function getCloudflareDomainsFromState() { return { domains: [], activeDomain: '' }; }
|
||||
function setCloudflareDomainEditMode() {}
|
||||
function getCloudflareTempEmailDomainsFromState() { return { domains: ['mail.example.com'], activeDomain: 'mail.example.com' }; }
|
||||
function setCloudflareTempEmailDomainEditMode(editing) { calls.tempDomainEditMode.push(editing); }
|
||||
function queueIcloudAliasRefresh() {}
|
||||
function hideIcloudLoginHelp() {}
|
||||
function syncMail2925PoolAccountOptions() {}
|
||||
function getMail2925Accounts() { return []; }
|
||||
function renderHotmailAccounts() {}
|
||||
function renderMail2925Accounts() {}
|
||||
function renderLuckmailPurchases() {}
|
||||
function getSelectedEmailGenerator() { return String(selectEmailGenerator.value || '').trim().toLowerCase(); }
|
||||
function isAutoRunLockedPhase() { return false; }
|
||||
${bundle}
|
||||
return {
|
||||
updateMailProviderUI,
|
||||
cloudflareTempEmailSection,
|
||||
rowTempEmailRandomSubdomainToggle,
|
||||
rowTempEmailDomain,
|
||||
inputTempEmailUseRandomSubdomain,
|
||||
autoHintText,
|
||||
calls,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.updateMailProviderUI();
|
||||
assert.equal(api.cloudflareTempEmailSection.style.display, '');
|
||||
assert.equal(api.rowTempEmailRandomSubdomainToggle.style.display, '');
|
||||
assert.equal(api.rowTempEmailDomain.style.display, '');
|
||||
|
||||
api.inputTempEmailUseRandomSubdomain.checked = true;
|
||||
api.updateMailProviderUI();
|
||||
assert.equal(api.cloudflareTempEmailSection.style.display, '');
|
||||
assert.equal(api.rowTempEmailDomain.style.display, '');
|
||||
assert.match(api.autoHintText.textContent, /RANDOM_SUBDOMAIN_DOMAINS/);
|
||||
});
|
||||
@@ -2,113 +2,28 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => sidepanelSource.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 < sidepanelSource.length; i += 1) {
|
||||
const ch = sidepanelSource[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < sidepanelSource.length; end += 1) {
|
||||
const ch = sidepanelSource[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
test('sidepanel html contains contribution button in header', () => {
|
||||
test('sidepanel html keeps a single contribution mode button in header', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
assert.match(html, /id="btn-contribution-mode"/);
|
||||
assert.match(html, />贡献</);
|
||||
const matches = html.match(/id="btn-contribution-mode"/g) || [];
|
||||
const serviceIndex = html.indexOf('<script src="contribution-content-update-service.js"></script>');
|
||||
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
|
||||
|
||||
assert.equal(matches.length, 1);
|
||||
assert.match(html, /id="btn-contribution-mode"[^>]*title="进入贡献模式并打开上传页"/);
|
||||
assert.match(html, />贡献\/使用<\/button>/);
|
||||
assert.match(html, /<\/header>\s*<div id="contribution-update-layer"/);
|
||||
assert.match(html, /id="contribution-update-layer"/);
|
||||
assert.match(html, /id="contribution-update-hint"/);
|
||||
assert.match(html, /id="contribution-update-hint-text"/);
|
||||
assert.match(html, /id="btn-dismiss-contribution-update-hint"/);
|
||||
assert.notEqual(serviceIndex, -1);
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(serviceIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('openContributionUploadPage opens upload page in a new tab directly', async () => {
|
||||
const bundle = [
|
||||
extractFunction('openContributionUploadPage'),
|
||||
].join('\n');
|
||||
test('sidepanel source no longer keeps the legacy upload-page handler on the header contribution button', () => {
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
const api = new Function(`
|
||||
const calls = [];
|
||||
const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/';
|
||||
function isContributionButtonLocked() {
|
||||
return false;
|
||||
}
|
||||
function openExternalUrl(url) {
|
||||
calls.push({ type: 'open', url });
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
openContributionUploadPage,
|
||||
getCalls() {
|
||||
return calls;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.openContributionUploadPage();
|
||||
assert.equal(result, true);
|
||||
assert.deepStrictEqual(api.getCalls(), [
|
||||
{
|
||||
type: 'open',
|
||||
url: 'https://apikey.qzz.io/',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('openContributionUploadPage blocks while flow is running', async () => {
|
||||
const bundle = [
|
||||
extractFunction('openContributionUploadPage'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const CONTRIBUTION_UPLOAD_URL = 'https://apikey.qzz.io/';
|
||||
function isContributionButtonLocked() {
|
||||
return true;
|
||||
}
|
||||
async function openConfirmModal() {
|
||||
throw new Error('should not open modal');
|
||||
}
|
||||
function openExternalUrl() {
|
||||
throw new Error('should not open url');
|
||||
}
|
||||
${bundle}
|
||||
return { openContributionUploadPage };
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.openContributionUploadPage(),
|
||||
/当前流程运行中/
|
||||
);
|
||||
assert.doesNotMatch(source, /openContributionUploadPage/);
|
||||
assert.doesNotMatch(source, /await openContributionUploadPage\(\)/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,488 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => sidepanelSource.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 < sidepanelSource.length; i += 1) {
|
||||
const ch = sidepanelSource[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < sidepanelSource.length; end += 1) {
|
||||
const ch = sidepanelSource[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
function createClassList() {
|
||||
const values = new Set();
|
||||
return {
|
||||
add(name) {
|
||||
values.add(name);
|
||||
},
|
||||
remove(name) {
|
||||
values.delete(name);
|
||||
},
|
||||
toggle(name, force) {
|
||||
if (force === undefined) {
|
||||
if (values.has(name)) {
|
||||
values.delete(name);
|
||||
return false;
|
||||
}
|
||||
values.add(name);
|
||||
return true;
|
||||
}
|
||||
if (force) {
|
||||
values.add(name);
|
||||
return true;
|
||||
}
|
||||
values.delete(name);
|
||||
return false;
|
||||
},
|
||||
contains(name) {
|
||||
return values.has(name);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createElement(initial = {}) {
|
||||
return {
|
||||
disabled: Boolean(initial.disabled),
|
||||
hidden: Boolean(initial.hidden),
|
||||
value: initial.value || '',
|
||||
title: '',
|
||||
textContent: initial.textContent || '',
|
||||
listeners: {},
|
||||
attributes: {},
|
||||
classList: createClassList(),
|
||||
addEventListener(type, handler) {
|
||||
this.listeners[type] = handler;
|
||||
},
|
||||
setAttribute(name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel html contains contribution mode runtime UI and loads the module before sidepanel bootstrap', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const moduleIndex = html.indexOf('<script src="contribution-mode.js"></script>');
|
||||
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
|
||||
|
||||
assert.match(html, /id="btn-contribution-mode"/);
|
||||
assert.match(html, /id="contribution-mode-panel"/);
|
||||
assert.match(html, /id="contribution-oauth-status"/);
|
||||
assert.match(html, /id="contribution-callback-status"/);
|
||||
assert.match(html, /id="contribution-mode-summary"/);
|
||||
assert.match(html, /id="btn-start-contribution"/);
|
||||
assert.match(html, /id="btn-open-contribution-upload"/);
|
||||
assert.match(html, /id="btn-exit-contribution-mode"/);
|
||||
assert.match(html, /id="input-contribution-nickname"/);
|
||||
assert.match(html, /id="input-contribution-qq"/);
|
||||
assert.notEqual(moduleIndex, -1);
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(moduleIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('collectSettingsPayload omits custom password and local sync settings in contribution mode', () => {
|
||||
const bundle = extractFunction('collectSettingsPayload');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = { contributionMode: true };
|
||||
let cloudflareDomainEditMode = false;
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
const selectCfDomain = { value: 'example.com' };
|
||||
const selectTempEmailDomain = { value: 'mail.example.com' };
|
||||
const selectPanelMode = { value: 'cpa' };
|
||||
const inputVpsUrl = { value: 'https://panel.example.com' };
|
||||
const inputVpsPassword = { value: 'panel-secret' };
|
||||
const inputSub2ApiUrl = { value: 'https://sub.example.com' };
|
||||
const inputSub2ApiEmail = { value: 'user@example.com' };
|
||||
const inputSub2ApiPassword = { value: 'sub-secret' };
|
||||
const inputSub2ApiGroup = { value: ' codex ' };
|
||||
const inputSub2ApiDefaultProxy = { value: ' proxy-a ' };
|
||||
const inputCodex2ApiUrl = { value: 'http://localhost:8080/admin/accounts' };
|
||||
const inputCodex2ApiAdminKey = { value: 'codex-admin-secret' };
|
||||
const inputPassword = { value: 'Secret123!' };
|
||||
const selectMailProvider = { value: '163' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
const checkboxAutoDeleteIcloud = { checked: true };
|
||||
const selectIcloudHostPreference = { value: 'auto' };
|
||||
const inputAccountRunHistoryTextEnabled = { checked: true };
|
||||
const inputAccountRunHistoryHelperBaseUrl = { value: 'http://127.0.0.1:17373' };
|
||||
const inputInbucketHost = { value: 'inbucket.local' };
|
||||
const inputInbucketMailbox = { value: 'demo' };
|
||||
const inputHotmailRemoteBaseUrl = { value: 'https://hotmail.example.com' };
|
||||
const inputHotmailLocalBaseUrl = { value: 'http://127.0.0.1:17373' };
|
||||
const inputLuckmailApiKey = { value: 'lk-api-key' };
|
||||
const inputLuckmailBaseUrl = { value: 'https://mails.example.com' };
|
||||
const selectLuckmailEmailType = { value: 'ms_graph' };
|
||||
const inputLuckmailDomain = { value: 'luckmail.example.com' };
|
||||
const inputTempEmailBaseUrl = { value: 'https://temp.example.com' };
|
||||
const inputTempEmailAdminAuth = { value: 'admin-secret' };
|
||||
const inputTempEmailCustomAuth = { value: 'custom-secret' };
|
||||
const inputTempEmailReceiveMailbox = { value: 'relay@example.com' };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: true };
|
||||
const inputAutoSkipFailures = { checked: false };
|
||||
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '5' };
|
||||
const inputAutoDelayEnabled = { checked: true };
|
||||
const inputAutoDelayMinutes = { value: '30' };
|
||||
const inputAutoStepDelaySeconds = { value: '10' };
|
||||
const inputVerificationResendCount = { value: '6' };
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
|
||||
function getCloudflareDomainsFromState() { return { domains: ['example.com'], activeDomain: 'example.com' }; }
|
||||
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
|
||||
function getCloudflareTempEmailDomainsFromState() { return { domains: ['mail.example.com'], activeDomain: 'mail.example.com' }; }
|
||||
function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); }
|
||||
function getSelectedLocalCpaStep9Mode() { return 'submit'; }
|
||||
function getSelectedMail2925Mode() { return 'provide'; }
|
||||
function normalizeAccountRunHistoryHelperBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function buildManagedAliasBaseEmailPayload() { return { gmailBaseEmail: '', mail2925BaseEmail: '', emailPrefix: '' }; }
|
||||
function getSelectedHotmailServiceMode() { return 'local'; }
|
||||
function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeLuckmailEmailType(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailReceiveMailboxValue(value) { return String(value || '').trim(); }
|
||||
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
|
||||
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
|
||||
function normalizeVerificationResendCount(value, fallback) { return Number.isFinite(Number(value)) ? Number(value) : fallback; }
|
||||
${bundle}
|
||||
return {
|
||||
collectSettingsPayload,
|
||||
setLatestState(nextState) { latestState = nextState; },
|
||||
};
|
||||
`)();
|
||||
|
||||
const contributionPayload = api.collectSettingsPayload();
|
||||
assert.equal('customPassword' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryTextEnabled' in contributionPayload, false);
|
||||
assert.equal('accountRunHistoryHelperBaseUrl' in contributionPayload, false);
|
||||
assert.equal(contributionPayload.cloudflareTempEmailUseRandomSubdomain, true);
|
||||
|
||||
api.setLatestState({ contributionMode: false });
|
||||
const normalPayload = api.collectSettingsPayload();
|
||||
assert.equal(normalPayload.customPassword, 'Secret123!');
|
||||
assert.equal(normalPayload.accountRunHistoryTextEnabled, true);
|
||||
assert.equal(normalPayload.accountRunHistoryHelperBaseUrl, 'http://127.0.0.1:17373');
|
||||
assert.equal(normalPayload.codex2apiUrl, 'http://localhost:8080/admin/accounts');
|
||||
assert.equal(normalPayload.codex2apiAdminKey, 'codex-admin-secret');
|
||||
assert.equal(normalPayload.cloudflareTempEmailUseRandomSubdomain, true);
|
||||
});
|
||||
|
||||
test('contribution mode manager enters mode, starts main auto flow, polls contribution status, and exits cleanly', async () => {
|
||||
const source = fs.readFileSync('sidepanel/contribution-mode.js', 'utf8');
|
||||
const windowObject = {};
|
||||
const timers = [];
|
||||
|
||||
const api = new Function('window', 'setTimeout', 'clearTimeout', `${source}; return window.SidepanelContributionMode;`)(
|
||||
windowObject,
|
||||
(handler) => {
|
||||
timers.push(handler);
|
||||
return timers.length;
|
||||
},
|
||||
(id) => {
|
||||
if (timers[id - 1]) {
|
||||
timers[id - 1] = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(typeof api?.createContributionModeManager, 'function');
|
||||
|
||||
let latestState = {
|
||||
contributionMode: false,
|
||||
panelMode: 'sub2api',
|
||||
contributionSessionId: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: 'user@example.com',
|
||||
};
|
||||
let blocked = false;
|
||||
let appliedState = null;
|
||||
let statusState = null;
|
||||
let closeConfigMenuCount = 0;
|
||||
let closeAccountRecordsCount = 0;
|
||||
let contributionAutoRunStartCount = 0;
|
||||
let updatePanelModeCount = 0;
|
||||
let updateSyncUiCount = 0;
|
||||
let updateConfigMenuCount = 0;
|
||||
const toasts = [];
|
||||
const openedUrls = [];
|
||||
const sentMessages = [];
|
||||
|
||||
const dom = {
|
||||
btnConfigMenu: createElement(),
|
||||
btnContributionMode: createElement(),
|
||||
btnExitContributionMode: createElement(),
|
||||
btnOpenAccountRecords: createElement(),
|
||||
btnOpenContributionUpload: createElement(),
|
||||
btnStartContribution: createElement(),
|
||||
inputContributionNickname: createElement({ value: '贡献者昵称' }),
|
||||
inputContributionQq: createElement({ value: '123456' }),
|
||||
contributionCallbackStatus: createElement(),
|
||||
contributionModePanel: createElement({ hidden: true }),
|
||||
contributionModeSummary: createElement(),
|
||||
contributionModeText: createElement(),
|
||||
contributionOauthStatus: createElement(),
|
||||
rowAccountRunHistoryHelperBaseUrl: createElement(),
|
||||
rowAccountRunHistoryTextEnabled: createElement(),
|
||||
rowCustomPassword: createElement(),
|
||||
rowLocalCpaStep9Mode: createElement(),
|
||||
rowSub2ApiDefaultProxy: createElement(),
|
||||
rowSub2ApiEmail: createElement(),
|
||||
rowSub2ApiGroup: createElement(),
|
||||
rowSub2ApiPassword: createElement(),
|
||||
rowSub2ApiUrl: createElement(),
|
||||
rowCodex2ApiUrl: createElement(),
|
||||
rowCodex2ApiAdminKey: createElement(),
|
||||
rowVpsPassword: createElement(),
|
||||
rowVpsUrl: createElement(),
|
||||
selectPanelMode: createElement({ value: 'sub2api' }),
|
||||
};
|
||||
|
||||
const manager = api.createContributionModeManager({
|
||||
state: {
|
||||
getLatestState: () => latestState,
|
||||
},
|
||||
dom,
|
||||
helpers: {
|
||||
applySettingsState(nextState) {
|
||||
latestState = nextState;
|
||||
appliedState = nextState;
|
||||
},
|
||||
closeAccountRecordsPanel() {
|
||||
closeAccountRecordsCount += 1;
|
||||
},
|
||||
closeConfigMenu() {
|
||||
closeConfigMenuCount += 1;
|
||||
},
|
||||
getContributionNickname() {
|
||||
return latestState.email;
|
||||
},
|
||||
getContributionProfile() {
|
||||
return {
|
||||
nickname: dom.inputContributionNickname.value,
|
||||
qq: dom.inputContributionQq.value,
|
||||
};
|
||||
},
|
||||
isModeSwitchBlocked() {
|
||||
return blocked;
|
||||
},
|
||||
openConfirmModal: async () => {
|
||||
throw new Error('should not ask for confirmation before entering contribution mode');
|
||||
},
|
||||
openExternalUrl(url) {
|
||||
openedUrls.push(url);
|
||||
},
|
||||
showToast(message, type) {
|
||||
toasts.push({ message, type });
|
||||
},
|
||||
async startContributionAutoRun() {
|
||||
contributionAutoRunStartCount += 1;
|
||||
latestState = {
|
||||
...latestState,
|
||||
contributionSessionId: 'session-002',
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002',
|
||||
contributionAuthState: 'oauth-state-002',
|
||||
contributionStatus: 'started',
|
||||
contributionStatusMessage: '\u5df2\u751f\u6210\u767b\u5f55\u5730\u5740',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
return true;
|
||||
},
|
||||
updateAccountRunHistorySettingsUI() {
|
||||
updateSyncUiCount += 1;
|
||||
},
|
||||
updateConfigMenuControls() {
|
||||
updateConfigMenuCount += 1;
|
||||
},
|
||||
updatePanelModeUI() {
|
||||
updatePanelModeCount += 1;
|
||||
},
|
||||
updateStatusDisplay(nextState) {
|
||||
statusState = nextState;
|
||||
},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async (message) => {
|
||||
sentMessages.push(message);
|
||||
if (message.type === 'SET_CONTRIBUTION_MODE') {
|
||||
return {
|
||||
state: message.payload.enabled
|
||||
? {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: latestState.email,
|
||||
}
|
||||
: {
|
||||
contributionMode: false,
|
||||
panelMode: 'cpa',
|
||||
contributionNickname: '',
|
||||
contributionQq: '',
|
||||
contributionSessionId: '',
|
||||
contributionAuthUrl: '',
|
||||
contributionAuthState: '',
|
||||
contributionCallbackUrl: '',
|
||||
contributionStatus: '',
|
||||
contributionStatusMessage: '',
|
||||
contributionCallbackStatus: 'idle',
|
||||
contributionCallbackMessage: '',
|
||||
email: latestState.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (message.type === 'POLL_CONTRIBUTION_STATUS') {
|
||||
return {
|
||||
state: {
|
||||
...latestState,
|
||||
contributionStatus: 'processing',
|
||||
contributionStatusMessage: '已提交回调,等待 CPA 确认',
|
||||
contributionCallbackStatus: 'submitted',
|
||||
contributionCallbackMessage: '已提交回调',
|
||||
},
|
||||
};
|
||||
}
|
||||
if (message.type === 'SET_CONTRIBUTION_PROFILE') {
|
||||
latestState = {
|
||||
...latestState,
|
||||
contributionNickname: message.payload.nickname,
|
||||
contributionQq: message.payload.qq,
|
||||
};
|
||||
return { state: latestState };
|
||||
}
|
||||
return {};
|
||||
},
|
||||
},
|
||||
constants: {
|
||||
contributionUploadUrl: 'https://apikey.qzz.io',
|
||||
pollIntervalMs: 2500,
|
||||
},
|
||||
});
|
||||
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.disabled, false);
|
||||
|
||||
manager.bindEvents();
|
||||
await dom.btnContributionMode.listeners.click();
|
||||
|
||||
assert.equal(dom.contributionModePanel.hidden, false);
|
||||
assert.equal(dom.selectPanelMode.value, 'cpa');
|
||||
assert.equal(dom.selectPanelMode.disabled, true);
|
||||
assert.equal(dom.btnOpenAccountRecords.disabled, true);
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u672a\u751f\u6210\u767b\u5f55\u5730\u5740');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u7b49\u5f85\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent.length > 0, true);
|
||||
assert.equal(dom.btnContributionMode.classList.contains('is-active'), true);
|
||||
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), true);
|
||||
assert.equal(dom.rowCodex2ApiUrl.classList.contains('is-contribution-hidden'), true);
|
||||
assert.equal(dom.rowCodex2ApiAdminKey.classList.contains('is-contribution-hidden'), true);
|
||||
assert.ok(closeConfigMenuCount >= 1);
|
||||
assert.ok(closeAccountRecordsCount >= 1);
|
||||
assert.ok(updatePanelModeCount >= 1);
|
||||
assert.ok(updateSyncUiCount >= 1);
|
||||
assert.ok(updateConfigMenuCount >= 1);
|
||||
assert.equal(timers.length, 0);
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io']);
|
||||
|
||||
dom.inputContributionNickname.value = '贡献者昵称';
|
||||
dom.inputContributionQq.value = '123456';
|
||||
|
||||
await dom.btnStartContribution.listeners.click();
|
||||
assert.equal(contributionAutoRunStartCount, 1);
|
||||
assert.equal(appliedState.contributionSessionId, '');
|
||||
assert.equal(latestState.contributionSessionId, 'session-002');
|
||||
assert.equal(latestState.contributionStatus, 'started');
|
||||
const contributionProfileMessage = sentMessages.find((message) => message.type === 'SET_CONTRIBUTION_PROFILE');
|
||||
assert.deepStrictEqual(contributionProfileMessage?.payload, { nickname: '贡献者昵称', qq: '123456' });
|
||||
assert.equal(timers.length > 0, true);
|
||||
|
||||
await manager.pollOnce({ reason: 'test_poll' });
|
||||
assert.equal(statusState.contributionStatus, 'processing');
|
||||
assert.equal(dom.contributionOauthStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionCallbackStatus.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03');
|
||||
assert.equal(dom.contributionModeSummary.textContent, '\u5df2\u63d0\u4ea4\u56de\u8c03\uff0c\u7b49\u5f85 CPA \u786e\u8ba4');
|
||||
|
||||
dom.btnOpenContributionUpload.listeners.click();
|
||||
assert.deepStrictEqual(openedUrls, ['https://apikey.qzz.io', 'https://apikey.qzz.io']);
|
||||
|
||||
await dom.btnExitContributionMode.listeners.click();
|
||||
manager.render();
|
||||
assert.equal(dom.contributionModePanel.hidden, true);
|
||||
assert.equal(dom.btnContributionMode.classList.contains('is-active'), false);
|
||||
assert.equal(dom.selectPanelMode.disabled, false);
|
||||
assert.equal(dom.rowVpsUrl.classList.contains('is-contribution-hidden'), false);
|
||||
assert.deepStrictEqual(
|
||||
sentMessages.map((message) => message.type),
|
||||
['SET_CONTRIBUTION_MODE', 'SET_CONTRIBUTION_PROFILE', 'POLL_CONTRIBUTION_STATUS', 'SET_CONTRIBUTION_MODE']
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
toasts.map((item) => item.message),
|
||||
['\u5df2\u8fdb\u5165\u8d21\u732e\u6a21\u5f0f\u3002', '\u8d21\u732e\u81ea\u52a8\u6d41\u7a0b\u5df2\u542f\u52a8\u3002', '\u5df2\u9000\u51fa\u8d21\u732e\u6a21\u5f0f\u3002']
|
||||
);
|
||||
|
||||
blocked = true;
|
||||
latestState = {
|
||||
contributionMode: true,
|
||||
panelMode: 'cpa',
|
||||
contributionNickname: '贡献者昵称',
|
||||
contributionQq: '123456',
|
||||
contributionSessionId: 'session-002',
|
||||
contributionAuthUrl: 'https://auth.example.com/oauth?state=oauth-state-002',
|
||||
contributionStatus: 'waiting',
|
||||
contributionStatusMessage: '\u7b49\u5f85\u6388\u6743\u5b8c\u6210',
|
||||
contributionCallbackStatus: 'waiting',
|
||||
contributionCallbackMessage: '\u7b49\u5f85\u56de\u8c03',
|
||||
};
|
||||
manager.render();
|
||||
assert.equal(dom.btnExitContributionMode.disabled, true);
|
||||
manager.stopPolling();
|
||||
});
|
||||
@@ -2,19 +2,74 @@ const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function createAccountPoolUiStub() {
|
||||
return {
|
||||
createAccountPoolFormController({
|
||||
formShell,
|
||||
toggleButton,
|
||||
hiddenLabel = '添加账号',
|
||||
visibleLabel = '取消添加',
|
||||
onClear,
|
||||
onFocus,
|
||||
} = {}) {
|
||||
let visible = false;
|
||||
|
||||
function sync() {
|
||||
if (formShell) {
|
||||
formShell.hidden = !visible;
|
||||
}
|
||||
if (toggleButton) {
|
||||
toggleButton.textContent = visible ? visibleLabel : hiddenLabel;
|
||||
toggleButton.setAttribute?.('aria-expanded', String(visible));
|
||||
}
|
||||
}
|
||||
|
||||
function setVisible(nextVisible, options = {}) {
|
||||
visible = Boolean(nextVisible);
|
||||
if (options.clearForm) {
|
||||
onClear?.();
|
||||
}
|
||||
sync();
|
||||
if (visible && options.focusField) {
|
||||
onFocus?.();
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
return {
|
||||
isVisible: () => visible,
|
||||
setVisible,
|
||||
sync,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel loads hotmail manager before sidepanel bootstrap', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const helperIndex = html.indexOf('<script src="account-pool-ui.js"></script>');
|
||||
const hotmailManagerIndex = html.indexOf('<script src="hotmail-manager.js"></script>');
|
||||
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
|
||||
|
||||
assert.notEqual(helperIndex, -1);
|
||||
assert.notEqual(hotmailManagerIndex, -1);
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(helperIndex < hotmailManagerIndex);
|
||||
assert.ok(hotmailManagerIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('sidepanel html contains collapsible hotmail form controls', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
assert.match(html, /id="btn-toggle-hotmail-form"/);
|
||||
assert.match(html, /id="hotmail-form-shell"/);
|
||||
assert.match(html, /id="btn-import-hotmail-accounts"[^>]*>批量导入</);
|
||||
});
|
||||
|
||||
test('hotmail manager exposes a factory and renders empty state', () => {
|
||||
const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
|
||||
const windowObject = {};
|
||||
const windowObject = {
|
||||
SidepanelAccountPoolUi: createAccountPoolUiStub(),
|
||||
};
|
||||
const localStorageMock = {
|
||||
getItem() {
|
||||
return null;
|
||||
@@ -77,3 +132,199 @@ test('hotmail manager exposes a factory and renders empty state', () => {
|
||||
manager.renderHotmailAccounts();
|
||||
assert.match(hotmailAccountsList.innerHTML, /还没有 Hotmail 账号/);
|
||||
});
|
||||
|
||||
test('hotmail manager toggles form container from header button', () => {
|
||||
const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
|
||||
const windowObject = {
|
||||
SidepanelAccountPoolUi: createAccountPoolUiStub(),
|
||||
};
|
||||
const localStorageMock = {
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
setItem() {},
|
||||
};
|
||||
|
||||
const api = new Function('window', 'localStorage', `${source}; return window.SidepanelHotmailManager;`)(
|
||||
windowObject,
|
||||
localStorageMock
|
||||
);
|
||||
|
||||
const handlers = {};
|
||||
const toggleFormButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'click') handlers.toggleForm = handler;
|
||||
},
|
||||
};
|
||||
const formShell = { hidden: true };
|
||||
|
||||
const manager = api.createHotmailManager({
|
||||
state: {
|
||||
getLatestState: () => ({ currentHotmailAccountId: null }),
|
||||
syncLatestState() {},
|
||||
},
|
||||
dom: {
|
||||
btnAddHotmailAccount: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnClearUsedHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnDeleteAllHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnHotmailUsageGuide: { addEventListener() {} },
|
||||
btnImportHotmailAccounts: { disabled: false, addEventListener() {} },
|
||||
btnToggleHotmailForm: toggleFormButton,
|
||||
btnToggleHotmailList: { textContent: '', disabled: false, setAttribute() {}, addEventListener() {} },
|
||||
hotmailAccountsList: { innerHTML: '', addEventListener() {} },
|
||||
hotmailFormShell: formShell,
|
||||
hotmailListShell: { classList: { toggle() {} } },
|
||||
inputEmail: { value: '' },
|
||||
inputHotmailClientId: { value: '' },
|
||||
inputHotmailEmail: { value: '', focus() { this.focused = true; } },
|
||||
inputHotmailImport: { value: '' },
|
||||
inputHotmailPassword: { value: '' },
|
||||
inputHotmailRefreshToken: { value: '' },
|
||||
selectMailProvider: { value: 'hotmail-api' },
|
||||
},
|
||||
helpers: {
|
||||
getHotmailAccounts: () => [],
|
||||
getCurrentHotmailEmail: () => '',
|
||||
escapeHtml: (value) => String(value || ''),
|
||||
showToast() {},
|
||||
openConfirmModal: async () => true,
|
||||
copyTextToClipboard: async () => {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => ({}),
|
||||
},
|
||||
constants: {
|
||||
copyIcon: '',
|
||||
displayTimeZone: 'Asia/Shanghai',
|
||||
expandedStorageKey: 'multipage-hotmail-list-expanded',
|
||||
},
|
||||
hotmailUtils: {},
|
||||
});
|
||||
|
||||
manager.bindHotmailEvents();
|
||||
assert.equal(formShell.hidden, true);
|
||||
assert.equal(toggleFormButton.textContent, '添加账号');
|
||||
|
||||
handlers.toggleForm();
|
||||
assert.equal(formShell.hidden, false);
|
||||
assert.equal(toggleFormButton.textContent, '取消添加');
|
||||
|
||||
handlers.toggleForm();
|
||||
assert.equal(formShell.hidden, true);
|
||||
assert.equal(toggleFormButton.textContent, '添加账号');
|
||||
});
|
||||
|
||||
test('hotmail manager hides form after save succeeds', async () => {
|
||||
const source = fs.readFileSync('sidepanel/hotmail-manager.js', 'utf8');
|
||||
const windowObject = {
|
||||
SidepanelAccountPoolUi: createAccountPoolUiStub(),
|
||||
};
|
||||
const localStorageMock = {
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
setItem() {},
|
||||
};
|
||||
|
||||
const api = new Function('window', 'localStorage', `${source}; return window.SidepanelHotmailManager;`)(
|
||||
windowObject,
|
||||
localStorageMock
|
||||
);
|
||||
|
||||
const handlers = {};
|
||||
const formShell = { hidden: true };
|
||||
const toggleFormButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
setAttribute() {},
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'click') handlers.toggleForm = handler;
|
||||
},
|
||||
};
|
||||
const addButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'click') handlers.add = handler;
|
||||
},
|
||||
};
|
||||
const inputHotmailEmail = { value: '', focus() {} };
|
||||
const inputHotmailClientId = { value: '' };
|
||||
const inputHotmailPassword = { value: '' };
|
||||
const inputHotmailRefreshToken = { value: '' };
|
||||
const toastMessages = [];
|
||||
|
||||
const manager = api.createHotmailManager({
|
||||
state: {
|
||||
getLatestState: () => ({ currentHotmailAccountId: null }),
|
||||
syncLatestState() {},
|
||||
},
|
||||
dom: {
|
||||
btnAddHotmailAccount: addButton,
|
||||
btnClearUsedHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnDeleteAllHotmailAccounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnHotmailUsageGuide: { addEventListener() {} },
|
||||
btnImportHotmailAccounts: { disabled: false, addEventListener() {} },
|
||||
btnToggleHotmailForm: toggleFormButton,
|
||||
btnToggleHotmailList: { textContent: '', disabled: false, setAttribute() {}, addEventListener() {} },
|
||||
hotmailAccountsList: { innerHTML: '', addEventListener() {} },
|
||||
hotmailFormShell: formShell,
|
||||
hotmailListShell: { classList: { toggle() {} } },
|
||||
inputEmail: { value: '' },
|
||||
inputHotmailClientId,
|
||||
inputHotmailEmail,
|
||||
inputHotmailImport: { value: '' },
|
||||
inputHotmailPassword,
|
||||
inputHotmailRefreshToken,
|
||||
selectMailProvider: { value: 'hotmail-api' },
|
||||
},
|
||||
helpers: {
|
||||
getHotmailAccounts: () => [],
|
||||
getCurrentHotmailEmail: () => '',
|
||||
escapeHtml: (value) => String(value || ''),
|
||||
showToast(message) {
|
||||
toastMessages.push(message);
|
||||
},
|
||||
openConfirmModal: async () => true,
|
||||
copyTextToClipboard: async () => {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => ({
|
||||
account: {
|
||||
id: 'acc-1',
|
||||
email: 'demo@hotmail.com',
|
||||
clientId: 'client-id',
|
||||
refreshToken: 'refresh-token',
|
||||
},
|
||||
}),
|
||||
},
|
||||
constants: {
|
||||
copyIcon: '',
|
||||
displayTimeZone: 'Asia/Shanghai',
|
||||
expandedStorageKey: 'multipage-hotmail-list-expanded',
|
||||
},
|
||||
hotmailUtils: {},
|
||||
});
|
||||
|
||||
manager.bindHotmailEvents();
|
||||
handlers.toggleForm();
|
||||
inputHotmailEmail.value = 'demo@hotmail.com';
|
||||
inputHotmailClientId.value = 'client-id';
|
||||
inputHotmailPassword.value = 'secret';
|
||||
inputHotmailRefreshToken.value = 'refresh-token';
|
||||
|
||||
await handlers.add();
|
||||
|
||||
assert.equal(formShell.hidden, true);
|
||||
assert.equal(toggleFormButton.textContent, '添加账号');
|
||||
assert.equal(inputHotmailEmail.value, '');
|
||||
assert.equal(inputHotmailClientId.value, '');
|
||||
assert.equal(inputHotmailPassword.value, '');
|
||||
assert.equal(inputHotmailRefreshToken.value, '');
|
||||
assert.match(toastMessages.at(-1) || '', /已保存 Hotmail 账号/);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const sidepanelSource = fs.readFileSync('sidepanel/sidepanel.js', 'utf8');
|
||||
|
||||
function extractFunction(name) {
|
||||
const markers = [`async function ${name}(`, `function ${name}(`];
|
||||
const start = markers
|
||||
.map((marker) => sidepanelSource.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 < sidepanelSource.length; i += 1) {
|
||||
const ch = sidepanelSource[i];
|
||||
if (ch === '(') {
|
||||
parenDepth += 1;
|
||||
} else if (ch === ')') {
|
||||
parenDepth -= 1;
|
||||
if (parenDepth === 0) {
|
||||
signatureEnded = true;
|
||||
}
|
||||
} else if (ch === '{' && signatureEnded) {
|
||||
braceStart = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let end = braceStart;
|
||||
for (; end < sidepanelSource.length; end += 1) {
|
||||
const ch = sidepanelSource[end];
|
||||
if (ch === '{') depth += 1;
|
||||
if (ch === '}') {
|
||||
depth -= 1;
|
||||
if (depth === 0) {
|
||||
end += 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sidepanelSource.slice(start, end);
|
||||
}
|
||||
|
||||
test('syncSelectedMail2925PoolAccount writes selected pool email back to mail2925BaseEmail', async () => {
|
||||
const bundle = [
|
||||
extractFunction('getMail2925Accounts'),
|
||||
extractFunction('getCurrentMail2925Account'),
|
||||
extractFunction('getCurrentMail2925Email'),
|
||||
extractFunction('isMail2925AccountPoolEnabled'),
|
||||
extractFunction('syncMail2925PoolAccountOptions'),
|
||||
extractFunction('getPreferredMail2925PoolAccountId'),
|
||||
extractFunction('syncSelectedMail2925PoolAccount'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = {
|
||||
mail2925UseAccountPool: true,
|
||||
mail2925BaseEmail: 'old@2925.com',
|
||||
currentMail2925AccountId: '',
|
||||
mail2925Accounts: [{ id: 'acc-1', email: 'new@2925.com' }],
|
||||
};
|
||||
const selectMail2925PoolAccount = { value: 'acc-1', innerHTML: '' };
|
||||
const chrome = {
|
||||
runtime: {
|
||||
async sendMessage() {
|
||||
return { account: { id: 'acc-1', email: 'new@2925.com' } };
|
||||
},
|
||||
},
|
||||
};
|
||||
const toastEvents = [];
|
||||
function syncLatestState(patch) {
|
||||
latestState = { ...latestState, ...patch };
|
||||
}
|
||||
function setManagedAliasBaseEmailInputForProvider() {}
|
||||
function showToast(message) {
|
||||
toastEvents.push(message);
|
||||
}
|
||||
function escapeHtml(value) {
|
||||
return String(value || '');
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
syncSelectedMail2925PoolAccount,
|
||||
getLatestState() {
|
||||
return latestState;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await api.syncSelectedMail2925PoolAccount({ silent: true });
|
||||
|
||||
assert.equal(api.getLatestState().currentMail2925AccountId, 'acc-1');
|
||||
assert.equal(api.getLatestState().mail2925BaseEmail, 'new@2925.com');
|
||||
});
|
||||
|
||||
test('syncMail2925BaseEmailFromCurrentAccount reuses current pool account email for manual base email field', async () => {
|
||||
const bundle = [
|
||||
extractFunction('getMail2925Accounts'),
|
||||
extractFunction('getCurrentMail2925Account'),
|
||||
extractFunction('getCurrentMail2925Email'),
|
||||
extractFunction('isMail2925AccountPoolEnabled'),
|
||||
extractFunction('syncMail2925BaseEmailFromCurrentAccount'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = {
|
||||
mail2925UseAccountPool: true,
|
||||
mail2925BaseEmail: 'old@2925.com',
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
mail2925Accounts: [{ id: 'acc-1', email: 'new@2925.com' }],
|
||||
};
|
||||
let saveCalls = 0;
|
||||
function syncLatestState(patch) {
|
||||
latestState = { ...latestState, ...patch };
|
||||
}
|
||||
async function saveSettings() {
|
||||
saveCalls += 1;
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
syncMail2925BaseEmailFromCurrentAccount,
|
||||
getLatestState() {
|
||||
return latestState;
|
||||
},
|
||||
getSaveCalls() {
|
||||
return saveCalls;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const changed = api.syncMail2925BaseEmailFromCurrentAccount(undefined, { persist: true });
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(changed, true);
|
||||
assert.equal(api.getLatestState().mail2925BaseEmail, 'new@2925.com');
|
||||
assert.equal(api.getSaveCalls(), 1);
|
||||
});
|
||||
|
||||
test('collectSettingsPayload persists currentMail2925AccountId for 2925 account pool restore', () => {
|
||||
const bundle = [
|
||||
extractFunction('collectSettingsPayload'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
let latestState = {
|
||||
contributionMode: false,
|
||||
mail2925UseAccountPool: true,
|
||||
currentMail2925AccountId: 'acc-2',
|
||||
};
|
||||
let cloudflareDomainEditMode = false;
|
||||
let cloudflareTempEmailDomainEditMode = false;
|
||||
const selectCfDomain = { value: 'example.com' };
|
||||
const selectTempEmailDomain = { value: 'mail.example.com' };
|
||||
const selectPanelMode = { value: 'cpa' };
|
||||
const inputVpsUrl = { value: '' };
|
||||
const inputVpsPassword = { value: '' };
|
||||
const inputSub2ApiUrl = { value: '' };
|
||||
const inputSub2ApiEmail = { value: '' };
|
||||
const inputSub2ApiPassword = { value: '' };
|
||||
const inputSub2ApiGroup = { value: '' };
|
||||
const inputSub2ApiDefaultProxy = { value: '' };
|
||||
const inputCodex2ApiUrl = { value: '' };
|
||||
const inputCodex2ApiAdminKey = { value: '' };
|
||||
const inputPassword = { value: '' };
|
||||
const selectMailProvider = { value: '2925' };
|
||||
const selectEmailGenerator = { value: 'duck' };
|
||||
const checkboxAutoDeleteIcloud = { checked: false };
|
||||
const selectIcloudHostPreference = { value: 'auto' };
|
||||
const inputAccountRunHistoryTextEnabled = { checked: false };
|
||||
const inputAccountRunHistoryHelperBaseUrl = { value: '' };
|
||||
const inputMail2925UseAccountPool = { checked: true };
|
||||
const inputInbucketHost = { value: '' };
|
||||
const inputInbucketMailbox = { value: '' };
|
||||
const inputHotmailRemoteBaseUrl = { value: '' };
|
||||
const inputHotmailLocalBaseUrl = { value: '' };
|
||||
const inputLuckmailApiKey = { value: '' };
|
||||
const inputLuckmailBaseUrl = { value: '' };
|
||||
const selectLuckmailEmailType = { value: 'ms_graph' };
|
||||
const inputLuckmailDomain = { value: '' };
|
||||
const inputTempEmailBaseUrl = { value: '' };
|
||||
const inputTempEmailAdminAuth = { value: '' };
|
||||
const inputTempEmailCustomAuth = { value: '' };
|
||||
const inputTempEmailReceiveMailbox = { value: '' };
|
||||
const inputTempEmailUseRandomSubdomain = { checked: false };
|
||||
const inputAutoSkipFailures = { checked: false };
|
||||
const inputAutoSkipFailuresThreadIntervalMinutes = { value: '0' };
|
||||
const inputAutoDelayEnabled = { checked: false };
|
||||
const inputAutoDelayMinutes = { value: '30' };
|
||||
const inputAutoStepDelaySeconds = { value: '' };
|
||||
const inputVerificationResendCount = { value: '4' };
|
||||
const DEFAULT_VERIFICATION_RESEND_COUNT = 4;
|
||||
function getCloudflareDomainsFromState() {
|
||||
return { domains: [], activeDomain: '' };
|
||||
}
|
||||
function normalizeCloudflareDomainValue(value) { return String(value || '').trim(); }
|
||||
function getCloudflareTempEmailDomainsFromState() {
|
||||
return { domains: [], activeDomain: '' };
|
||||
}
|
||||
function normalizeCloudflareTempEmailDomainValue(value) { return String(value || '').trim(); }
|
||||
function getSelectedLocalCpaStep9Mode() { return 'submit'; }
|
||||
function getSelectedMail2925Mode() { return 'provide'; }
|
||||
function getSelectedHotmailServiceMode() { return 'local'; }
|
||||
function buildManagedAliasBaseEmailPayload() {
|
||||
return { gmailBaseEmail: '', mail2925BaseEmail: 'demo@2925.com', emailPrefix: '' };
|
||||
}
|
||||
function normalizeLuckmailBaseUrl(value) { return String(value || '').trim(); }
|
||||
function normalizeLuckmailEmailType(value) { return String(value || '').trim() || 'ms_graph'; }
|
||||
function normalizeCloudflareTempEmailBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function normalizeCloudflareTempEmailReceiveMailboxValue(value) { return String(value || '').trim(); }
|
||||
function normalizeAccountRunHistoryHelperBaseUrlValue(value) { return String(value || '').trim(); }
|
||||
function normalizeAutoRunThreadIntervalMinutes(value) { return Number(value) || 0; }
|
||||
function normalizeAutoDelayMinutes(value) { return Number(value) || 30; }
|
||||
function normalizeAutoStepDelaySeconds(value) { return value === '' ? null : Number(value); }
|
||||
function normalizeVerificationResendCount(value, fallback) { return Number(value) || fallback; }
|
||||
${bundle}
|
||||
return { collectSettingsPayload };
|
||||
`)();
|
||||
|
||||
const payload = api.collectSettingsPayload();
|
||||
|
||||
assert.equal(payload.currentMail2925AccountId, 'acc-2');
|
||||
assert.equal(payload.mail2925UseAccountPool, true);
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function createAccountPoolUiStub() {
|
||||
return {
|
||||
createAccountPoolFormController({
|
||||
formShell,
|
||||
toggleButton,
|
||||
hiddenLabel = '添加账号',
|
||||
visibleLabel = '取消添加',
|
||||
onClear,
|
||||
onFocus,
|
||||
} = {}) {
|
||||
let visible = false;
|
||||
|
||||
function sync() {
|
||||
if (formShell) {
|
||||
formShell.hidden = !visible;
|
||||
}
|
||||
if (toggleButton) {
|
||||
toggleButton.textContent = visible ? visibleLabel : hiddenLabel;
|
||||
toggleButton.setAttribute?.('aria-expanded', String(visible));
|
||||
}
|
||||
}
|
||||
|
||||
function setVisible(nextVisible, options = {}) {
|
||||
visible = Boolean(nextVisible);
|
||||
if (options.clearForm) {
|
||||
onClear?.();
|
||||
}
|
||||
sync();
|
||||
if (visible && options.focusField) {
|
||||
onFocus?.();
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
return {
|
||||
isVisible: () => visible,
|
||||
setVisible,
|
||||
sync,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel html contains collapsible mail2925 form controls', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
assert.match(html, /id="btn-toggle-mail2925-form"/);
|
||||
assert.match(html, /id="mail2925-form-shell"/);
|
||||
assert.doesNotMatch(html, /id="btn-cancel-mail2925-edit"/);
|
||||
});
|
||||
|
||||
test('mail2925 manager renders edit action for existing accounts', () => {
|
||||
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
|
||||
const windowObject = {
|
||||
SidepanelAccountPoolUi: createAccountPoolUiStub(),
|
||||
};
|
||||
const localStorageMock = {
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
setItem() {},
|
||||
};
|
||||
|
||||
const api = new Function('window', 'localStorage', `${source}; return window.SidepanelMail2925Manager;`)(
|
||||
windowObject,
|
||||
localStorageMock
|
||||
);
|
||||
|
||||
const mail2925AccountsList = { innerHTML: '', addEventListener() {} };
|
||||
const manager = api.createMail2925Manager({
|
||||
state: {
|
||||
getLatestState: () => ({
|
||||
currentMail2925AccountId: 'acc-1',
|
||||
mail2925Accounts: [{
|
||||
id: 'acc-1',
|
||||
email: 'demo@2925.com',
|
||||
password: 'secret',
|
||||
enabled: true,
|
||||
lastLoginAt: 0,
|
||||
lastUsedAt: 0,
|
||||
lastLimitAt: 0,
|
||||
disabledUntil: 0,
|
||||
lastError: '',
|
||||
}],
|
||||
}),
|
||||
syncLatestState() {},
|
||||
},
|
||||
dom: {
|
||||
btnAddMail2925Account: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnDeleteAllMail2925Accounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnImportMail2925Accounts: { disabled: false, addEventListener() {} },
|
||||
btnToggleMail2925Form: { textContent: '', setAttribute() {}, addEventListener() {} },
|
||||
btnToggleMail2925List: { textContent: '', disabled: false, setAttribute() {}, addEventListener() {} },
|
||||
inputMail2925Email: { value: '' },
|
||||
inputMail2925Import: { value: '' },
|
||||
inputMail2925Password: { value: '' },
|
||||
mail2925AccountsList,
|
||||
mail2925FormShell: { hidden: true },
|
||||
mail2925ListShell: { classList: { toggle() {} } },
|
||||
},
|
||||
helpers: {
|
||||
getMail2925Accounts: (state) => state.mail2925Accounts || [],
|
||||
escapeHtml: (value) => String(value || ''),
|
||||
showToast() {},
|
||||
openConfirmModal: async () => true,
|
||||
copyTextToClipboard: async () => {},
|
||||
refreshManagedAliasBaseEmail() {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => ({}),
|
||||
},
|
||||
constants: {
|
||||
copyIcon: '',
|
||||
displayTimeZone: 'Asia/Shanghai',
|
||||
expandedStorageKey: 'multipage-mail2925-list-expanded',
|
||||
},
|
||||
mail2925Utils: {
|
||||
getMail2925AccountStatus: () => 'ready',
|
||||
getMail2925ListToggleLabel: () => '展开列表(1)',
|
||||
upsertMail2925AccountInList: (_accounts, nextAccount) => [nextAccount],
|
||||
},
|
||||
});
|
||||
|
||||
manager.renderMail2925Accounts();
|
||||
assert.match(mail2925AccountsList.innerHTML, /data-account-action="edit"/);
|
||||
});
|
||||
@@ -0,0 +1,342 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
function createAccountPoolUiStub() {
|
||||
return {
|
||||
createAccountPoolFormController({
|
||||
formShell,
|
||||
toggleButton,
|
||||
hiddenLabel = '添加账号',
|
||||
visibleLabel = '取消添加',
|
||||
onClear,
|
||||
onFocus,
|
||||
} = {}) {
|
||||
let visible = false;
|
||||
|
||||
function sync() {
|
||||
if (formShell) {
|
||||
formShell.hidden = !visible;
|
||||
}
|
||||
if (toggleButton) {
|
||||
toggleButton.textContent = visible ? visibleLabel : hiddenLabel;
|
||||
toggleButton.setAttribute?.('aria-expanded', String(visible));
|
||||
}
|
||||
}
|
||||
|
||||
function setVisible(nextVisible, options = {}) {
|
||||
visible = Boolean(nextVisible);
|
||||
if (options.clearForm) {
|
||||
onClear?.();
|
||||
}
|
||||
sync();
|
||||
if (visible && options.focusField) {
|
||||
onFocus?.();
|
||||
}
|
||||
}
|
||||
|
||||
sync();
|
||||
return {
|
||||
isVisible: () => visible,
|
||||
setVisible,
|
||||
sync,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('sidepanel loads mail2925 manager before sidepanel bootstrap', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
const helperIndex = html.indexOf('<script src="account-pool-ui.js"></script>');
|
||||
const managerIndex = html.indexOf('<script src="mail-2925-manager.js"></script>');
|
||||
const sidepanelIndex = html.indexOf('<script src="sidepanel.js"></script>');
|
||||
|
||||
assert.notEqual(helperIndex, -1);
|
||||
assert.notEqual(managerIndex, -1);
|
||||
assert.notEqual(sidepanelIndex, -1);
|
||||
assert.ok(helperIndex < managerIndex);
|
||||
assert.ok(managerIndex < sidepanelIndex);
|
||||
});
|
||||
|
||||
test('sidepanel html contains mail2925 pool toggle and selector controls', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
|
||||
assert.match(html, /id="input-mail2925-use-account-pool"/);
|
||||
assert.match(html, /id="select-mail2925-pool-account"/);
|
||||
assert.match(html, /id="btn-toggle-mail2925-form"/);
|
||||
assert.match(html, /id="mail2925-form-shell"/);
|
||||
assert.doesNotMatch(html, /id="btn-cancel-mail2925-edit"/);
|
||||
});
|
||||
|
||||
test('sidepanel css keeps collapsed shared mailbox list near a single card height', () => {
|
||||
const css = fs.readFileSync('sidepanel/sidepanel.css', 'utf8');
|
||||
assert.match(css, /\.hotmail-list-shell\.is-collapsed\s*\{\s*max-height:\s*176px;\s*\}/);
|
||||
});
|
||||
|
||||
test('mail2925 manager exposes a factory and renders empty state', () => {
|
||||
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
|
||||
const windowObject = {
|
||||
SidepanelAccountPoolUi: createAccountPoolUiStub(),
|
||||
};
|
||||
const localStorageMock = {
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
setItem() {},
|
||||
};
|
||||
|
||||
const api = new Function('window', 'localStorage', `${source}; return window.SidepanelMail2925Manager;`)(
|
||||
windowObject,
|
||||
localStorageMock
|
||||
);
|
||||
|
||||
assert.equal(typeof api?.createMail2925Manager, 'function');
|
||||
|
||||
const mail2925AccountsList = { innerHTML: '', addEventListener() {} };
|
||||
const formToggleButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
setAttribute() {},
|
||||
addEventListener() {},
|
||||
};
|
||||
const toggleButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
setAttribute() {},
|
||||
addEventListener() {},
|
||||
};
|
||||
const noopClassList = { toggle() {} };
|
||||
|
||||
const manager = api.createMail2925Manager({
|
||||
state: {
|
||||
getLatestState: () => ({ currentMail2925AccountId: null, mail2925Accounts: [] }),
|
||||
syncLatestState() {},
|
||||
},
|
||||
dom: {
|
||||
btnAddMail2925Account: { disabled: false, addEventListener() {} },
|
||||
btnDeleteAllMail2925Accounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnImportMail2925Accounts: { disabled: false, addEventListener() {} },
|
||||
btnToggleMail2925Form: formToggleButton,
|
||||
btnToggleMail2925List: toggleButton,
|
||||
inputMail2925Email: { value: '' },
|
||||
inputMail2925Import: { value: '' },
|
||||
inputMail2925Password: { value: '' },
|
||||
mail2925AccountsList,
|
||||
mail2925FormShell: { hidden: true },
|
||||
mail2925ListShell: { classList: noopClassList },
|
||||
},
|
||||
helpers: {
|
||||
getMail2925Accounts: () => [],
|
||||
escapeHtml: (value) => String(value || ''),
|
||||
showToast() {},
|
||||
openConfirmModal: async () => true,
|
||||
copyTextToClipboard: async () => {},
|
||||
refreshManagedAliasBaseEmail() {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => ({}),
|
||||
},
|
||||
constants: {
|
||||
copyIcon: '',
|
||||
displayTimeZone: 'Asia/Shanghai',
|
||||
expandedStorageKey: 'multipage-mail2925-list-expanded',
|
||||
},
|
||||
mail2925Utils: {},
|
||||
});
|
||||
|
||||
assert.equal(typeof manager.renderMail2925Accounts, 'function');
|
||||
assert.equal(typeof manager.bindMail2925Events, 'function');
|
||||
assert.equal(typeof manager.initMail2925ListExpandedState, 'function');
|
||||
|
||||
manager.renderMail2925Accounts();
|
||||
assert.match(mail2925AccountsList.innerHTML, /还没有 2925 账号/);
|
||||
});
|
||||
|
||||
test('mail2925 manager toggles form container from header button', () => {
|
||||
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
|
||||
const windowObject = {
|
||||
SidepanelAccountPoolUi: createAccountPoolUiStub(),
|
||||
};
|
||||
const localStorageMock = {
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
setItem() {},
|
||||
};
|
||||
|
||||
const api = new Function('window', 'localStorage', `${source}; return window.SidepanelMail2925Manager;`)(
|
||||
windowObject,
|
||||
localStorageMock
|
||||
);
|
||||
|
||||
const clickHandlers = {};
|
||||
const formToggleButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
setAttribute(name, value) {
|
||||
this[name] = value;
|
||||
},
|
||||
addEventListener(type, handler) {
|
||||
clickHandlers[type] = handler;
|
||||
},
|
||||
};
|
||||
const formShell = { hidden: true };
|
||||
|
||||
const manager = api.createMail2925Manager({
|
||||
state: {
|
||||
getLatestState: () => ({ currentMail2925AccountId: null, mail2925Accounts: [] }),
|
||||
syncLatestState() {},
|
||||
},
|
||||
dom: {
|
||||
btnAddMail2925Account: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnDeleteAllMail2925Accounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnImportMail2925Accounts: { disabled: false, addEventListener() {} },
|
||||
btnToggleMail2925Form: formToggleButton,
|
||||
btnToggleMail2925List: { textContent: '', disabled: false, setAttribute() {}, addEventListener() {} },
|
||||
inputMail2925Email: { value: '', focus() { this.focused = true; } },
|
||||
inputMail2925Import: { value: '' },
|
||||
inputMail2925Password: { value: '' },
|
||||
mail2925AccountsList: { innerHTML: '', addEventListener() {} },
|
||||
mail2925FormShell: formShell,
|
||||
mail2925ListShell: { classList: { toggle() {} } },
|
||||
},
|
||||
helpers: {
|
||||
getMail2925Accounts: () => [],
|
||||
escapeHtml: (value) => String(value || ''),
|
||||
showToast() {},
|
||||
openConfirmModal: async () => true,
|
||||
copyTextToClipboard: async () => {},
|
||||
refreshManagedAliasBaseEmail() {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => ({}),
|
||||
},
|
||||
constants: {
|
||||
copyIcon: '',
|
||||
displayTimeZone: 'Asia/Shanghai',
|
||||
expandedStorageKey: 'multipage-mail2925-list-expanded',
|
||||
},
|
||||
mail2925Utils: {},
|
||||
});
|
||||
|
||||
manager.bindMail2925Events();
|
||||
assert.equal(formShell.hidden, true);
|
||||
assert.equal(formToggleButton.textContent, '添加账号');
|
||||
|
||||
clickHandlers.click();
|
||||
assert.equal(formShell.hidden, false);
|
||||
assert.equal(formToggleButton.textContent, '取消添加');
|
||||
|
||||
clickHandlers.click();
|
||||
assert.equal(formShell.hidden, true);
|
||||
assert.equal(formToggleButton.textContent, '添加账号');
|
||||
});
|
||||
|
||||
test('mail2925 manager hides form after save succeeds', async () => {
|
||||
const source = fs.readFileSync('sidepanel/mail-2925-manager.js', 'utf8');
|
||||
const windowObject = {
|
||||
SidepanelAccountPoolUi: createAccountPoolUiStub(),
|
||||
};
|
||||
const localStorageMock = {
|
||||
getItem() {
|
||||
return null;
|
||||
},
|
||||
setItem() {},
|
||||
};
|
||||
|
||||
const api = new Function('window', 'localStorage', `${source}; return window.SidepanelMail2925Manager;`)(
|
||||
windowObject,
|
||||
localStorageMock
|
||||
);
|
||||
|
||||
let latestState = { currentMail2925AccountId: null, mail2925Accounts: [] };
|
||||
const handlers = {};
|
||||
const formToggleButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
setAttribute() {},
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'click') handlers.toggle = handler;
|
||||
},
|
||||
};
|
||||
const addButton = {
|
||||
textContent: '',
|
||||
disabled: false,
|
||||
addEventListener(type, handler) {
|
||||
if (type === 'click') handlers.add = handler;
|
||||
},
|
||||
};
|
||||
const formShell = { hidden: true };
|
||||
const inputMail2925Email = { value: '', focus() {} };
|
||||
const inputMail2925Password = { value: '' };
|
||||
const toastMessages = [];
|
||||
|
||||
const manager = api.createMail2925Manager({
|
||||
state: {
|
||||
getLatestState: () => latestState,
|
||||
syncLatestState(patch) {
|
||||
latestState = { ...latestState, ...patch };
|
||||
},
|
||||
},
|
||||
dom: {
|
||||
btnAddMail2925Account: addButton,
|
||||
btnDeleteAllMail2925Accounts: { textContent: '', disabled: false, addEventListener() {} },
|
||||
btnImportMail2925Accounts: { disabled: false, addEventListener() {} },
|
||||
btnToggleMail2925Form: formToggleButton,
|
||||
btnToggleMail2925List: { textContent: '', disabled: false, setAttribute() {}, addEventListener() {} },
|
||||
inputMail2925Email,
|
||||
inputMail2925Import: { value: '' },
|
||||
inputMail2925Password,
|
||||
mail2925AccountsList: { innerHTML: '', addEventListener() {} },
|
||||
mail2925FormShell: formShell,
|
||||
mail2925ListShell: { classList: { toggle() {} } },
|
||||
},
|
||||
helpers: {
|
||||
getMail2925Accounts: (state) => state.mail2925Accounts || [],
|
||||
escapeHtml: (value) => String(value || ''),
|
||||
showToast(message) {
|
||||
toastMessages.push(message);
|
||||
},
|
||||
openConfirmModal: async () => true,
|
||||
copyTextToClipboard: async () => {},
|
||||
refreshManagedAliasBaseEmail() {},
|
||||
},
|
||||
runtime: {
|
||||
sendMessage: async () => ({
|
||||
account: {
|
||||
id: 'acc-1',
|
||||
email: 'demo@2925.com',
|
||||
password: 'secret',
|
||||
enabled: true,
|
||||
lastLoginAt: 0,
|
||||
lastUsedAt: 0,
|
||||
lastLimitAt: 0,
|
||||
disabledUntil: 0,
|
||||
lastError: '',
|
||||
},
|
||||
}),
|
||||
},
|
||||
constants: {
|
||||
copyIcon: '',
|
||||
displayTimeZone: 'Asia/Shanghai',
|
||||
expandedStorageKey: 'multipage-mail2925-list-expanded',
|
||||
},
|
||||
mail2925Utils: {
|
||||
upsertMail2925AccountInList: (accounts, nextAccount) => accounts.concat(nextAccount),
|
||||
},
|
||||
});
|
||||
|
||||
manager.bindMail2925Events();
|
||||
handlers.toggle();
|
||||
inputMail2925Email.value = 'demo@2925.com';
|
||||
inputMail2925Password.value = 'secret';
|
||||
|
||||
await handlers.add();
|
||||
|
||||
assert.equal(formShell.hidden, true);
|
||||
assert.equal(formToggleButton.textContent, '添加账号');
|
||||
assert.equal(addButton.textContent, '添加账号');
|
||||
assert.equal(inputMail2925Email.value, '');
|
||||
assert.equal(inputMail2925Password.value, '');
|
||||
assert.match(toastMessages.at(-1) || '', /已保存 2925 账号/);
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.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('sidepanel html keeps 2925 mode row and standalone pool settings row', () => {
|
||||
const html = fs.readFileSync('sidepanel/sidepanel.html', 'utf8');
|
||||
|
||||
assert.match(html, /id="row-mail-2925-mode"/);
|
||||
assert.match(html, /data-mail2925-mode="provide"/);
|
||||
assert.match(html, /data-mail2925-mode="receive"/);
|
||||
assert.match(html, /id="row-mail2925-pool-settings"/);
|
||||
});
|
||||
|
||||
test('sidepanel only treats 2925 as generated alias provider in provide mode', () => {
|
||||
const bundle = [
|
||||
extractFunction('isManagedAliasProvider'),
|
||||
extractFunction('usesGeneratedAliasMailProvider'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const GMAIL_PROVIDER = 'gmail';
|
||||
const MAIL_2925_MODE_PROVIDE = 'provide';
|
||||
const MAIL_2925_MODE_RECEIVE = 'receive';
|
||||
const DEFAULT_MAIL_2925_MODE = MAIL_2925_MODE_PROVIDE;
|
||||
const selectMailProvider = { value: '2925' };
|
||||
|
||||
function normalizeMail2925Mode(value = '') {
|
||||
return String(value || '').trim().toLowerCase() === MAIL_2925_MODE_RECEIVE
|
||||
? MAIL_2925_MODE_RECEIVE
|
||||
: DEFAULT_MAIL_2925_MODE;
|
||||
}
|
||||
|
||||
function getSelectedMail2925Mode() {
|
||||
return MAIL_2925_MODE_PROVIDE;
|
||||
}
|
||||
|
||||
function getManagedAliasUtils() {
|
||||
return {
|
||||
usesManagedAliasGeneration(provider, options = {}) {
|
||||
return String(provider || '').trim().toLowerCase() === 'gmail'
|
||||
|| (String(provider || '').trim().toLowerCase() === '2925'
|
||||
&& normalizeMail2925Mode(options.mail2925Mode) === MAIL_2925_MODE_PROVIDE);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
isManagedAliasProvider,
|
||||
usesGeneratedAliasMailProvider,
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.isManagedAliasProvider('2925', 'provide'), true);
|
||||
assert.equal(api.isManagedAliasProvider('2925', 'receive'), false);
|
||||
assert.equal(api.usesGeneratedAliasMailProvider('2925', 'provide'), true);
|
||||
assert.equal(api.usesGeneratedAliasMailProvider('2925', 'receive'), false);
|
||||
assert.equal(api.usesGeneratedAliasMailProvider('gmail', 'receive'), true);
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('sidepanel/sidepanel.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;
|
||||
}
|
||||
}
|
||||
|
||||
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('new user guide prompt is only eligible before the one-time dismissal is set', () => {
|
||||
const bundle = [
|
||||
extractFunction('isPromptDismissed'),
|
||||
extractFunction('setPromptDismissed'),
|
||||
extractFunction('isNewUserGuidePromptDismissed'),
|
||||
extractFunction('setNewUserGuidePromptDismissed'),
|
||||
extractFunction('shouldPromptNewUserGuide'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed';
|
||||
const storage = new Map();
|
||||
const localStorage = {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
};
|
||||
const btnContributionMode = { disabled: false };
|
||||
let latestState = { contributionMode: false };
|
||||
${bundle}
|
||||
return {
|
||||
shouldPromptNewUserGuide,
|
||||
setDismissed(value) {
|
||||
setNewUserGuidePromptDismissed(value);
|
||||
},
|
||||
setButtonDisabled(value) {
|
||||
btnContributionMode.disabled = Boolean(value);
|
||||
},
|
||||
setContributionMode(value) {
|
||||
latestState = { contributionMode: Boolean(value) };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.equal(api.shouldPromptNewUserGuide(), true);
|
||||
|
||||
api.setDismissed(true);
|
||||
assert.equal(api.shouldPromptNewUserGuide(), false);
|
||||
|
||||
api.setDismissed(false);
|
||||
api.setButtonDisabled(true);
|
||||
assert.equal(api.shouldPromptNewUserGuide(), false);
|
||||
|
||||
api.setButtonDisabled(false);
|
||||
api.setContributionMode(true);
|
||||
assert.equal(api.shouldPromptNewUserGuide(), false);
|
||||
});
|
||||
|
||||
test('new user guide prompt persists dismissal before awaiting the user choice and opens the contribution page on confirm', async () => {
|
||||
const bundle = [
|
||||
extractFunction('isPromptDismissed'),
|
||||
extractFunction('setPromptDismissed'),
|
||||
extractFunction('isNewUserGuidePromptDismissed'),
|
||||
extractFunction('setNewUserGuidePromptDismissed'),
|
||||
extractFunction('shouldPromptNewUserGuide'),
|
||||
extractFunction('getContributionPortalUrl'),
|
||||
extractFunction('openNewUserGuidePrompt'),
|
||||
extractFunction('maybeShowNewUserGuidePrompt'),
|
||||
].join('\n');
|
||||
|
||||
const api = new Function(`
|
||||
const NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY = 'multipage-new-user-guide-prompt-dismissed';
|
||||
const storage = new Map();
|
||||
const localStorage = {
|
||||
getItem(key) {
|
||||
return storage.has(key) ? storage.get(key) : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
storage.set(key, String(value));
|
||||
},
|
||||
removeItem(key) {
|
||||
storage.delete(key);
|
||||
},
|
||||
};
|
||||
const btnContributionMode = { disabled: false };
|
||||
const latestState = { contributionMode: false };
|
||||
const contributionContentService = { portalUrl: 'https://apikey.qzz.io' };
|
||||
const openedUrls = [];
|
||||
let modalOptions = null;
|
||||
let nextChoice = 'confirm';
|
||||
function openExternalUrl(url) {
|
||||
openedUrls.push(url);
|
||||
}
|
||||
function openActionModal(options) {
|
||||
modalOptions = options;
|
||||
return Promise.resolve(nextChoice);
|
||||
}
|
||||
${bundle}
|
||||
return {
|
||||
maybeShowNewUserGuidePrompt,
|
||||
getDismissed() {
|
||||
return localStorage.getItem(NEW_USER_GUIDE_PROMPT_DISMISSED_STORAGE_KEY);
|
||||
},
|
||||
getOpenedUrls() {
|
||||
return openedUrls.slice();
|
||||
},
|
||||
getModalOptions() {
|
||||
return modalOptions;
|
||||
},
|
||||
setNextChoice(choice) {
|
||||
nextChoice = choice;
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const confirmed = await api.maybeShowNewUserGuidePrompt();
|
||||
const modalOptions = api.getModalOptions();
|
||||
|
||||
assert.equal(confirmed, true);
|
||||
assert.equal(api.getDismissed(), '1');
|
||||
assert.deepStrictEqual(api.getOpenedUrls(), ['https://apikey.qzz.io']);
|
||||
assert.equal(modalOptions.title, '新手引导');
|
||||
assert.equal(modalOptions.alert.text, '本提示仅出现一次。');
|
||||
assert.deepStrictEqual(
|
||||
modalOptions.actions.map((item) => ({ id: item.id, label: item.label })),
|
||||
[
|
||||
{ id: null, label: '取消' },
|
||||
{ id: 'confirm', label: '查看引导' },
|
||||
]
|
||||
);
|
||||
|
||||
api.setNextChoice(null);
|
||||
const skipped = await api.maybeShowNewUserGuidePrompt();
|
||||
assert.equal(skipped, false);
|
||||
assert.deepStrictEqual(api.getOpenedUrls(), ['https://apikey.qzz.io']);
|
||||
});
|
||||
@@ -144,3 +144,317 @@ return {
|
||||
]);
|
||||
assert.match(result.bodyTextPreview, /Welcome to ChatGPT/);
|
||||
});
|
||||
|
||||
test('signup entry diagnostics captures hidden signup button style and blocking ancestor details', () => {
|
||||
const api = new Function(`
|
||||
const SIGNUP_ENTRY_TRIGGER_PATTERN = /免费注册|立即注册|注册|sign\\s*up|register|create\\s*account|create\\s+account/i;
|
||||
const location = { href: 'https://chatgpt.com/' };
|
||||
const hiddenSection = {
|
||||
tagName: 'DIV',
|
||||
id: 'mobile-cta',
|
||||
className: 'max-xs:hidden',
|
||||
hidden: false,
|
||||
parentElement: null,
|
||||
hasAttribute() {
|
||||
return false;
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'aria-hidden') return '';
|
||||
return '';
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 0, height: 0 };
|
||||
},
|
||||
_style: {
|
||||
display: 'none',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const hiddenSignupButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Sign up for free',
|
||||
disabled: false,
|
||||
className: 'signup-button',
|
||||
hidden: false,
|
||||
parentElement: hiddenSection,
|
||||
hasAttribute() {
|
||||
return false;
|
||||
},
|
||||
getBoundingClientRect() {
|
||||
return { width: 0, height: 0 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return '';
|
||||
if (name === 'aria-hidden') return '';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const document = {
|
||||
title: 'ChatGPT',
|
||||
readyState: 'complete',
|
||||
querySelector() {
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'a, button, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
|
||||
return [hiddenSignupButton];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
outerWidth: 390,
|
||||
outerHeight: 844,
|
||||
devicePixelRatio: 3,
|
||||
getComputedStyle(el) {
|
||||
return el?._style || {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function isVisibleElement(el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
}
|
||||
|
||||
function getActionText(el) {
|
||||
return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function isActionEnabled(el) {
|
||||
return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function getSignupEmailInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getPageTextSnapshot() {
|
||||
return 'ChatGPT 登录';
|
||||
}
|
||||
|
||||
${extractFunction('getSignupEntryDiagnostics')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return getSignupEntryDiagnostics();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = api.run();
|
||||
|
||||
assert.deepStrictEqual(result.viewport, {
|
||||
innerWidth: 390,
|
||||
innerHeight: 844,
|
||||
outerWidth: 390,
|
||||
outerHeight: 844,
|
||||
devicePixelRatio: 3,
|
||||
});
|
||||
assert.deepStrictEqual(result.signupLikeActionCounts, {
|
||||
total: 1,
|
||||
visible: 0,
|
||||
hidden: 1,
|
||||
});
|
||||
assert.equal(result.signupLikeActions[0]?.text, 'Sign up for free');
|
||||
assert.equal(result.signupLikeActions[0]?.className, 'signup-button');
|
||||
assert.equal(result.signupLikeActions[0]?.display, 'block');
|
||||
assert.equal(result.signupLikeActions[0]?.blockingAncestor?.className, 'max-xs:hidden');
|
||||
assert.equal(result.signupLikeActions[0]?.blockingAncestor?.display, 'none');
|
||||
});
|
||||
|
||||
test('signup password diagnostics summarizes password inputs, submit button, and alternate code entry', () => {
|
||||
const api = new Function(`
|
||||
const location = { href: 'https://auth.openai.com/create-account/password' };
|
||||
const form = { action: 'https://auth.openai.com/u/signup/password' };
|
||||
const passwordInput = {
|
||||
tagName: 'INPUT',
|
||||
type: 'password',
|
||||
name: 'new-password',
|
||||
id: 'password-field',
|
||||
value: 'SecretLength14',
|
||||
className: 'password-input',
|
||||
disabled: false,
|
||||
form,
|
||||
getBoundingClientRect() {
|
||||
return { width: 320, height: 44 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'password';
|
||||
if (name === 'name') return 'new-password';
|
||||
if (name === 'autocomplete') return 'new-password';
|
||||
if (name === 'placeholder') return 'Password';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const submitButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Continue',
|
||||
className: 'submit-btn',
|
||||
disabled: false,
|
||||
form,
|
||||
getBoundingClientRect() {
|
||||
return { width: 160, height: 40 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'submit';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
if (name === 'data-dd-action-name') return 'Continue';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const oneTimeCodeButton = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Use a one-time code instead',
|
||||
className: 'switch-btn',
|
||||
disabled: false,
|
||||
getBoundingClientRect() {
|
||||
return { width: 220, height: 36 };
|
||||
},
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'button';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
_style: {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
},
|
||||
};
|
||||
const document = {
|
||||
title: 'Create your account',
|
||||
readyState: 'complete',
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[type="password"], input[name*="password" i], input[autocomplete="new-password"], input[autocomplete="current-password"]') {
|
||||
return [passwordInput];
|
||||
}
|
||||
if (selector === 'button, a, [role="button"], [role="link"], input[type="button"], input[type="submit"]') {
|
||||
return [submitButton, oneTimeCodeButton];
|
||||
}
|
||||
return [];
|
||||
},
|
||||
};
|
||||
const window = {
|
||||
getComputedStyle(el) {
|
||||
return el?._style || {
|
||||
display: 'block',
|
||||
visibility: 'visible',
|
||||
opacity: '1',
|
||||
pointerEvents: 'auto',
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
function isVisibleElement(el) {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.display !== 'none'
|
||||
&& style.visibility !== 'hidden'
|
||||
&& rect.width > 0
|
||||
&& rect.height > 0;
|
||||
}
|
||||
|
||||
function isActionEnabled(el) {
|
||||
return Boolean(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true';
|
||||
}
|
||||
|
||||
function getActionText(el) {
|
||||
return [el?.textContent, el?.value, el?.getAttribute?.('aria-label'), el?.getAttribute?.('title')]
|
||||
.filter(Boolean)
|
||||
.join(' ')
|
||||
.replace(/\\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function getPageTextSnapshot() {
|
||||
return 'Create your account Use a one-time code instead';
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return passwordInput;
|
||||
}
|
||||
|
||||
function getSignupPasswordSubmitButton() {
|
||||
return submitButton;
|
||||
}
|
||||
|
||||
function getSignupPasswordDisplayedEmail() {
|
||||
return 'user@example.com';
|
||||
}
|
||||
|
||||
function findOneTimeCodeLoginTrigger() {
|
||||
return oneTimeCodeButton;
|
||||
}
|
||||
|
||||
function getSignupPasswordTimeoutErrorPageState() {
|
||||
return {
|
||||
retryEnabled: true,
|
||||
userAlreadyExistsBlocked: false,
|
||||
};
|
||||
}
|
||||
|
||||
${extractFunction('getSignupPasswordDiagnostics')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return getSignupPasswordDiagnostics();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = api.run();
|
||||
|
||||
assert.equal(result.url, 'https://auth.openai.com/create-account/password');
|
||||
assert.equal(result.displayedEmail, 'user@example.com');
|
||||
assert.equal(result.hasVisiblePasswordInput, true);
|
||||
assert.equal(result.passwordInputCount, 1);
|
||||
assert.equal(result.visiblePasswordInputCount, 1);
|
||||
assert.equal(result.passwordInputs[0]?.name, 'new-password');
|
||||
assert.equal(result.passwordInputs[0]?.valueLength, 14);
|
||||
assert.equal(result.submitButton?.text, 'Continue');
|
||||
assert.equal(result.oneTimeCodeTrigger?.text, 'Use a one-time code instead');
|
||||
assert.equal(result.retryPage, true);
|
||||
assert.equal(result.retryEnabled, true);
|
||||
assert.match(result.bodyTextPreview, /one-time code/);
|
||||
});
|
||||
|
||||
@@ -138,5 +138,57 @@ return {
|
||||
assert.deepStrictEqual(api.run(), {
|
||||
state: 'error',
|
||||
retryButton: { textContent: 'Try again' },
|
||||
userAlreadyExistsBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('signup verification state treats email-verification retry page as error instead of verification', () => {
|
||||
const api = new Function(`
|
||||
const location = {
|
||||
pathname: '/email-verification',
|
||||
};
|
||||
|
||||
function getAuthTimeoutErrorPageState(options) {
|
||||
return options.pathPatterns.some((pattern) => pattern.test(location.pathname))
|
||||
? { retryButton: { textContent: 'Try again' } }
|
||||
: null;
|
||||
}
|
||||
|
||||
function isStep5Ready() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isVerificationPageStillVisible() {
|
||||
return true;
|
||||
}
|
||||
|
||||
function isSignupEmailAlreadyExistsPage() {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getSignupPasswordInput() {
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSignupPasswordSubmitButton() {
|
||||
return null;
|
||||
}
|
||||
|
||||
${extractFunction('getSignupAuthRetryPathPatterns')}
|
||||
${extractFunction('getSignupPasswordTimeoutErrorPageState')}
|
||||
${extractFunction('isSignupPasswordErrorPage')}
|
||||
${extractFunction('inspectSignupVerificationState')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return inspectSignupVerificationState();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
assert.deepStrictEqual(api.run(), {
|
||||
state: 'error',
|
||||
retryButton: { textContent: 'Try again' },
|
||||
userAlreadyExistsBlocked: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/signup-page.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('fillVerificationCode submits after split inputs are stably filled', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
const clicks = [];
|
||||
const filledValues = [];
|
||||
let submitClicked = false;
|
||||
const VERIFICATION_CODE_INPUT_SELECTOR = 'input[data-verification-code]';
|
||||
const location = { href: 'https://auth.openai.com/email-verification' };
|
||||
function KeyboardEvent(type, init = {}) {
|
||||
this.type = type;
|
||||
Object.assign(this, init);
|
||||
}
|
||||
|
||||
const submitBtn = {
|
||||
tagName: 'BUTTON',
|
||||
textContent: 'Continue',
|
||||
disabled: false,
|
||||
getAttribute(name) {
|
||||
if (name === 'type') return 'submit';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
click() {
|
||||
submitClicked = true;
|
||||
},
|
||||
};
|
||||
|
||||
const inputs = Array.from({ length: 6 }, () => ({
|
||||
value: '',
|
||||
maxLength: 1,
|
||||
getAttribute(name) {
|
||||
if (name === 'maxlength') return '1';
|
||||
if (name === 'aria-disabled') return 'false';
|
||||
return '';
|
||||
},
|
||||
focus() {},
|
||||
dispatchEvent() {},
|
||||
closest() { return null; },
|
||||
}));
|
||||
|
||||
const document = {
|
||||
querySelector(selector) {
|
||||
if (selector === VERIFICATION_CODE_INPUT_SELECTOR) return inputs[0];
|
||||
return null;
|
||||
},
|
||||
querySelectorAll(selector) {
|
||||
if (selector === 'input[maxlength="1"]') return inputs;
|
||||
if (selector === 'button[type="submit"], input[type="submit"]') return [submitBtn];
|
||||
if (selector === 'button, [role="button"], input[type="button"], input[type="submit"]') return [submitBtn];
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
function throwIfStopped() {}
|
||||
function log(message, level = 'info') { logs.push({ message, level }); }
|
||||
async function waitForLoginVerificationPageReady() {}
|
||||
function is405MethodNotAllowedPage() { return false; }
|
||||
async function handle405ResendError() {}
|
||||
function fillInput(el, value) {
|
||||
el.value = value;
|
||||
filledValues.push(value);
|
||||
}
|
||||
async function sleep() {}
|
||||
function isVisibleElement() { return true; }
|
||||
function isActionEnabled(el) { return Boolean(el) && !el.disabled; }
|
||||
function getActionText(el) { return el.textContent || ''; }
|
||||
async function humanPause() {}
|
||||
function simulateClick(el) { el.click(); clicks.push(el.textContent); }
|
||||
async function waitForVerificationSubmitOutcome() { return { success: true }; }
|
||||
|
||||
${extractFunction('getVisibleSplitVerificationInputs')}
|
||||
${extractFunction('getVerificationCodeTarget')}
|
||||
${extractFunction('getVerificationSubmitButtonForTarget')}
|
||||
${extractFunction('waitForVerificationSubmitButton')}
|
||||
${extractFunction('waitForVerificationCodeTarget')}
|
||||
${extractFunction('waitForSplitVerificationInputsFilled')}
|
||||
${extractFunction('fillVerificationCode')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return fillVerificationCode(4, { code: '123456' });
|
||||
},
|
||||
snapshot() {
|
||||
return {
|
||||
logs,
|
||||
clicks,
|
||||
filledValues,
|
||||
submitClicked,
|
||||
currentValue: inputs.map((input) => input.value).join(''),
|
||||
};
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.deepStrictEqual(result, { success: true });
|
||||
assert.equal(snapshot.currentValue, '123456');
|
||||
assert.equal(snapshot.submitClicked, true);
|
||||
assert.deepStrictEqual(snapshot.clicks, ['Continue']);
|
||||
});
|
||||
@@ -0,0 +1,151 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/signup-page.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('waitForVerificationSubmitOutcome recovers signup retry page after submit', async () => {
|
||||
const api = new Function(`
|
||||
let retryVisible = true;
|
||||
let step5Ready = false;
|
||||
let recoverCalls = 0;
|
||||
const location = { href: 'https://auth.openai.com/email-verification' };
|
||||
|
||||
function throwIfStopped() {}
|
||||
function log() {}
|
||||
function getVerificationErrorText() { return ''; }
|
||||
function isStep5Ready() { return step5Ready; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVerificationPageStillVisible() { return false; }
|
||||
function createSignupUserAlreadyExistsError() {
|
||||
return new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
|
||||
}
|
||||
function getCurrentAuthRetryPageState(flow) {
|
||||
if (flow === 'signup' && retryVisible) {
|
||||
return {
|
||||
retryEnabled: true,
|
||||
userAlreadyExistsBlocked: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
recoverCalls += 1;
|
||||
retryVisible = false;
|
||||
step5Ready = true;
|
||||
}
|
||||
async function sleep() {}
|
||||
|
||||
${extractFunction('waitForVerificationSubmitOutcome')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return waitForVerificationSubmitOutcome(4, 1000);
|
||||
},
|
||||
snapshot() {
|
||||
return { recoverCalls };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
|
||||
assert.deepStrictEqual(result, { success: true });
|
||||
assert.equal(api.snapshot().recoverCalls, 1);
|
||||
});
|
||||
|
||||
test('waitForVerificationSubmitOutcome does not assume success after repeated signup retry pages', async () => {
|
||||
const api = new Function(`
|
||||
let recoverCalls = 0;
|
||||
const location = { href: 'https://auth.openai.com/email-verification' };
|
||||
|
||||
function throwIfStopped() {}
|
||||
function log() {}
|
||||
function getVerificationErrorText() { return ''; }
|
||||
function isStep5Ready() { return false; }
|
||||
function isStep8Ready() { return false; }
|
||||
function isAddPhonePageReady() { return false; }
|
||||
function isVerificationPageStillVisible() { return false; }
|
||||
function createSignupUserAlreadyExistsError() {
|
||||
return new Error('SIGNUP_USER_ALREADY_EXISTS::步骤 4:检测到 user_already_exists,说明当前用户已存在,当前轮将直接停止。');
|
||||
}
|
||||
function getCurrentAuthRetryPageState(flow) {
|
||||
if (flow === 'signup') {
|
||||
return {
|
||||
retryEnabled: true,
|
||||
userAlreadyExistsBlocked: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
recoverCalls += 1;
|
||||
}
|
||||
async function sleep() {}
|
||||
|
||||
${extractFunction('waitForVerificationSubmitOutcome')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return waitForVerificationSubmitOutcome(4, 1000);
|
||||
},
|
||||
snapshot() {
|
||||
return { recoverCalls };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
api.run(),
|
||||
/连续进入认证重试页 2 次,页面仍未恢复/
|
||||
);
|
||||
assert.equal(api.snapshot().recoverCalls, 2);
|
||||
});
|
||||
@@ -146,6 +146,27 @@ return {
|
||||
assert.strictEqual(snapshot.displayedEmail, 'display.user@example.com');
|
||||
}
|
||||
|
||||
{
|
||||
const api = createApi({
|
||||
pathname: '/email-verification',
|
||||
retryState: {
|
||||
retryEnabled: true,
|
||||
titleMatched: false,
|
||||
detailMatched: false,
|
||||
routeErrorMatched: true,
|
||||
},
|
||||
verificationTarget: { id: 'otp' },
|
||||
verificationVisible: true,
|
||||
});
|
||||
|
||||
const snapshot = api.inspectLoginAuthState();
|
||||
assert.strictEqual(
|
||||
snapshot.state,
|
||||
'login_timeout_error_page',
|
||||
'第七步在 /email-verification 的登录重试页应优先识别为登录超时报错页'
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const api = createApi({
|
||||
oauthConsentPage: true,
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
|
||||
const source = fs.readFileSync('content/signup-page.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);
|
||||
}
|
||||
|
||||
const bundle = extractFunction('step6LoginFromPasswordPage');
|
||||
|
||||
function createApi() {
|
||||
return new Function(`
|
||||
${bundle}
|
||||
return { step6LoginFromPasswordPage };
|
||||
`)();
|
||||
}
|
||||
|
||||
function cleanupGlobals() {
|
||||
delete globalThis.normalizeStep6Snapshot;
|
||||
delete globalThis.inspectLoginAuthState;
|
||||
delete globalThis.log;
|
||||
delete globalThis.step6SwitchToOneTimeCodeLogin;
|
||||
delete globalThis.createStep6RecoverableResult;
|
||||
delete globalThis.fillInput;
|
||||
delete globalThis.humanPause;
|
||||
delete globalThis.sleep;
|
||||
delete globalThis.triggerLoginSubmitAction;
|
||||
delete globalThis.waitForStep6PasswordSubmitTransition;
|
||||
}
|
||||
|
||||
test('step6LoginFromPasswordPage switches to one-time-code login when password is missing but switch trigger exists', async () => {
|
||||
const api = createApi();
|
||||
const logs = [];
|
||||
const snapshot = {
|
||||
state: 'password_page',
|
||||
passwordInput: { id: 'password' },
|
||||
switchTrigger: { id: 'otp' },
|
||||
};
|
||||
|
||||
globalThis.normalizeStep6Snapshot = (value) => value;
|
||||
globalThis.inspectLoginAuthState = () => snapshot;
|
||||
globalThis.log = (message, level = 'info') => {
|
||||
logs.push({ message, level });
|
||||
};
|
||||
globalThis.step6SwitchToOneTimeCodeLogin = async (payload, value) => {
|
||||
assert.deepStrictEqual(payload, { email: 'user@example.com', password: '' });
|
||||
assert.strictEqual(value, snapshot);
|
||||
return { step6Outcome: 'success', via: 'switch_to_one_time_code_login' };
|
||||
};
|
||||
globalThis.createStep6RecoverableResult = () => {
|
||||
throw new Error('should not create recoverable result when switch trigger exists');
|
||||
};
|
||||
globalThis.fillInput = () => {
|
||||
throw new Error('should not fill password when password is missing');
|
||||
};
|
||||
globalThis.humanPause = async () => {};
|
||||
globalThis.sleep = async () => {};
|
||||
globalThis.triggerLoginSubmitAction = async () => {};
|
||||
globalThis.waitForStep6PasswordSubmitTransition = async () => {
|
||||
throw new Error('should not submit password when password is missing');
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await api.step6LoginFromPasswordPage({ email: 'user@example.com', password: '' }, snapshot);
|
||||
|
||||
assert.deepStrictEqual(result, { step6Outcome: 'success', via: 'switch_to_one_time_code_login' });
|
||||
assert.deepStrictEqual(logs, [
|
||||
{ message: '步骤 7:当前未提供密码,改走一次性验证码登录。', level: 'warn' },
|
||||
]);
|
||||
} finally {
|
||||
cleanupGlobals();
|
||||
}
|
||||
});
|
||||
|
||||
test('step6LoginFromPasswordPage returns a recoverable result when password is missing and no one-time-code trigger exists', async () => {
|
||||
const api = createApi();
|
||||
const snapshot = {
|
||||
state: 'password_page',
|
||||
passwordInput: { id: 'password' },
|
||||
switchTrigger: null,
|
||||
};
|
||||
|
||||
globalThis.normalizeStep6Snapshot = (value) => value;
|
||||
globalThis.inspectLoginAuthState = () => snapshot;
|
||||
globalThis.log = () => {};
|
||||
globalThis.step6SwitchToOneTimeCodeLogin = async () => {
|
||||
throw new Error('should not switch without a one-time-code trigger');
|
||||
};
|
||||
globalThis.createStep6RecoverableResult = (reason, stateSnapshot, details) => ({
|
||||
step6Outcome: 'recoverable',
|
||||
reason,
|
||||
stateSnapshot,
|
||||
...details,
|
||||
});
|
||||
globalThis.fillInput = () => {
|
||||
throw new Error('should not fill password when password is missing');
|
||||
};
|
||||
globalThis.humanPause = async () => {};
|
||||
globalThis.sleep = async () => {};
|
||||
globalThis.triggerLoginSubmitAction = async () => {};
|
||||
globalThis.waitForStep6PasswordSubmitTransition = async () => {
|
||||
throw new Error('should not submit password when password is missing');
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await api.step6LoginFromPasswordPage({ email: 'user@example.com', password: '' }, snapshot);
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
step6Outcome: 'recoverable',
|
||||
reason: 'missing_password_and_one_time_code_trigger',
|
||||
stateSnapshot: snapshot,
|
||||
message: '登录时未提供密码,且当前页面没有可用的一次性验证码登录入口。',
|
||||
});
|
||||
} finally {
|
||||
cleanupGlobals();
|
||||
}
|
||||
});
|
||||
@@ -72,12 +72,18 @@ async function recoverCurrentAuthRetryPage() {
|
||||
return { recovered: true };
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoverableResult')}
|
||||
|
||||
return {
|
||||
@@ -103,3 +109,248 @@ return {
|
||||
assert.equal(result.state, 'login_timeout_error_page');
|
||||
assert.equal(result.message, '当前页面处于登录超时报错页。');
|
||||
});
|
||||
|
||||
test('step 7 timeout recovery transition continues from password page after retry succeeds', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
let recoverCalls = 0;
|
||||
let currentState = 'login_timeout_error_page';
|
||||
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/log-in',
|
||||
};
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: currentState,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
recoverCalls += 1;
|
||||
currentState = 'password_page';
|
||||
return { recovered: true };
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return createStep6LoginTimeoutRecoveryTransition(
|
||||
'login_timeout_error_page',
|
||||
{ state: 'login_timeout_error_page', url: location.href },
|
||||
'当前页面处于登录超时报错页。',
|
||||
{
|
||||
via: 'login_timeout_initial_recovered',
|
||||
}
|
||||
);
|
||||
},
|
||||
snapshot() {
|
||||
return { logs, recoverCalls };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.equal(snapshot.recoverCalls, 1);
|
||||
assert.equal(result.action, 'password');
|
||||
assert.equal(result.snapshot.state, 'password_page');
|
||||
assert.equal(snapshot.logs.some(({ message }) => /密码页/.test(message)), true);
|
||||
});
|
||||
|
||||
test('step 7 entry resumes password flow after retry page recovery reaches password page', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
let recoverCalls = 0;
|
||||
let currentState = 'login_timeout_error_page';
|
||||
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/log-in',
|
||||
};
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: currentState,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
recoverCalls += 1;
|
||||
currentState = 'password_page';
|
||||
return { recovered: true };
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
async function step6LoginFromPasswordPage(payload, snapshot) {
|
||||
return { branch: 'password', payload, snapshot };
|
||||
}
|
||||
|
||||
async function step6LoginFromEmailPage(payload, snapshot) {
|
||||
return { branch: 'email', payload, snapshot };
|
||||
}
|
||||
|
||||
async function finalizeStep6VerificationReady(options) {
|
||||
return { branch: 'verification', options };
|
||||
}
|
||||
|
||||
function throwForStep6FatalState() {}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
${extractFunction('step6_login')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return step6_login({
|
||||
email: 'user@example.com',
|
||||
password: 'secret',
|
||||
});
|
||||
},
|
||||
snapshot() {
|
||||
return { logs, recoverCalls };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.equal(snapshot.recoverCalls, 1);
|
||||
assert.equal(result.branch, 'password');
|
||||
assert.equal(result.snapshot.state, 'password_page');
|
||||
assert.equal(snapshot.logs.some(({ message }) => /密码页/.test(message)), true);
|
||||
});
|
||||
|
||||
test('step 7 finalize converts verification page that falls into retry page into recoverable result', async () => {
|
||||
const api = new Function(`
|
||||
const logs = [];
|
||||
let recoverCalls = 0;
|
||||
let currentState = 'verification_page';
|
||||
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/email-verification',
|
||||
};
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: currentState,
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
async function recoverCurrentAuthRetryPage() {
|
||||
recoverCalls += 1;
|
||||
return { recovered: true };
|
||||
}
|
||||
|
||||
async function sleep() {
|
||||
currentState = 'login_timeout_error_page';
|
||||
}
|
||||
|
||||
function log(message, level = 'info') {
|
||||
logs.push({ message, level });
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
|
||||
function getLoginAuthStateLabel(snapshot) {
|
||||
switch (snapshot?.state) {
|
||||
case 'verification_page':
|
||||
return '登录验证码页';
|
||||
case 'login_timeout_error_page':
|
||||
return '登录超时报错页';
|
||||
default:
|
||||
return '未知页面';
|
||||
}
|
||||
}
|
||||
|
||||
${extractFunction('createStep6SuccessResult')}
|
||||
${extractFunction('createStep6RecoverableResult')}
|
||||
${extractFunction('normalizeStep6Snapshot')}
|
||||
${extractFunction('waitForKnownLoginAuthState')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoveryTransition')}
|
||||
${extractFunction('createStep6LoginTimeoutRecoverableResult')}
|
||||
${extractFunction('finalizeStep6VerificationReady')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return finalizeStep6VerificationReady({
|
||||
logLabel: '步骤 7 收尾',
|
||||
loginVerificationRequestedAt: 123,
|
||||
via: 'password_submit',
|
||||
});
|
||||
},
|
||||
snapshot() {
|
||||
return { logs, recoverCalls };
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.equal(snapshot.recoverCalls, 1);
|
||||
assert.equal(result.step6Outcome, 'recoverable');
|
||||
assert.equal(result.reason, 'login_timeout_error_page');
|
||||
assert.equal(result.state, 'login_timeout_error_page');
|
||||
assert.equal(result.message, '登录验证码页面准备就绪前进入登录超时报错页。');
|
||||
});
|
||||
|
||||
test('waitForLoginVerificationPageReady reports login timeout page without step8 restart prefix', async () => {
|
||||
const api = new Function(`
|
||||
const location = {
|
||||
href: 'https://auth.openai.com/email-verification',
|
||||
};
|
||||
|
||||
function inspectLoginAuthState() {
|
||||
return {
|
||||
state: 'login_timeout_error_page',
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleep() {}
|
||||
|
||||
function getLoginAuthStateLabel(snapshot) {
|
||||
return snapshot?.state === 'login_timeout_error_page' ? '登录超时报错页' : '未知页面';
|
||||
}
|
||||
|
||||
${extractFunction('waitForLoginVerificationPageReady')}
|
||||
|
||||
return {
|
||||
run() {
|
||||
return waitForLoginVerificationPageReady(10);
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.run(),
|
||||
/当前未进入登录验证码页面,请先重新完成步骤 7。当前状态:登录超时报错页。URL: https:\/\/auth\.openai\.com\/email-verification/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -117,10 +117,10 @@ return {
|
||||
|
||||
test('step 8 reruns step 7 when auth page enters login timeout retry state', async () => {
|
||||
const calls = {
|
||||
executeStep7: 0,
|
||||
rerunStep7: 0,
|
||||
ensureReady: 0,
|
||||
logs: [],
|
||||
sleeps: [],
|
||||
rerunOptions: [],
|
||||
resolveCalls: 0,
|
||||
};
|
||||
|
||||
@@ -142,8 +142,9 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
|
||||
}
|
||||
return { state: 'verification_page' };
|
||||
},
|
||||
executeStep7: async () => {
|
||||
calls.executeStep7 += 1;
|
||||
rerunStep7ForStep8Recovery: async (options) => {
|
||||
calls.rerunStep7 += 1;
|
||||
calls.rerunOptions.push(options || null);
|
||||
},
|
||||
getOAuthFlowRemainingMs: async () => 8000,
|
||||
getOAuthFlowStepTimeoutMs: async (defaultTimeoutMs) => Math.min(defaultTimeoutMs, 8000),
|
||||
@@ -167,9 +168,6 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
shouldUseCustomRegistrationEmail: () => false,
|
||||
sleepWithStop: async (ms) => {
|
||||
calls.sleeps.push(ms);
|
||||
},
|
||||
STANDARD_MAIL_VERIFICATION_RESEND_INTERVAL_MS: 25000,
|
||||
STEP7_MAIL_POLLING_RECOVERY_MAX_ATTEMPTS: 3,
|
||||
throwIfStopped: () => {},
|
||||
@@ -181,9 +179,13 @@ test('step 8 reruns step 7 when auth page enters login timeout retry state', asy
|
||||
oauthUrl: 'https://oauth.example/latest',
|
||||
});
|
||||
|
||||
assert.equal(calls.executeStep7, 1);
|
||||
assert.equal(calls.rerunStep7, 1);
|
||||
assert.equal(calls.ensureReady, 2);
|
||||
assert.equal(calls.resolveCalls, 1);
|
||||
assert.equal(calls.logs.some(({ message }) => /重新开始|重新发起/.test(message)), true);
|
||||
assert.deepStrictEqual(calls.sleeps, [3000]);
|
||||
assert.deepStrictEqual(calls.rerunOptions, [
|
||||
{
|
||||
logMessage: '步骤 8:认证页进入重试/超时报错状态,正在回到步骤 7 重新发起登录流程...',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -51,10 +51,8 @@ function extractFunction(name) {
|
||||
return source.slice(start, end);
|
||||
}
|
||||
|
||||
test('step 8 click effect returns restart_current_step when retry page is recovered', async () => {
|
||||
test('step 8 click effect throws when retry page appears after clicking continue', async () => {
|
||||
const api = new Function(`
|
||||
let recoverCalls = 0;
|
||||
|
||||
const chrome = {
|
||||
tabs: {
|
||||
async get() {
|
||||
@@ -69,10 +67,6 @@ const chrome = {
|
||||
function throwIfStopped() {}
|
||||
async function sleepWithStop() {}
|
||||
async function ensureStep8SignupPageReady() {}
|
||||
async function recoverAuthRetryPageOnTab() {
|
||||
recoverCalls += 1;
|
||||
return { recovered: true };
|
||||
}
|
||||
async function getStep8PageState() {
|
||||
return {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
@@ -89,20 +83,51 @@ return {
|
||||
async run() {
|
||||
return waitForStep8ClickEffect(88, 'https://auth.openai.com/authorize', 1000);
|
||||
},
|
||||
snapshot() {
|
||||
return { recoverCalls };
|
||||
};
|
||||
`)();
|
||||
|
||||
await assert.rejects(
|
||||
() => api.run(),
|
||||
/点击“继续”后页面进入认证页重试页/
|
||||
);
|
||||
});
|
||||
|
||||
test('step 8 ready check throws when consent page is already a retry page before clicking', async () => {
|
||||
const api = new Function(`
|
||||
const chrome = {
|
||||
tabs: {
|
||||
async get() {
|
||||
return {
|
||||
id: 88,
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
function throwIfStopped() {}
|
||||
async function sleepWithStop() {}
|
||||
async function ensureStep8SignupPageReady() {}
|
||||
async function getStep8PageState() {
|
||||
return {
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
retryPage: true,
|
||||
addPhonePage: false,
|
||||
consentReady: false,
|
||||
};
|
||||
}
|
||||
|
||||
${extractFunction('waitForStep8Ready')}
|
||||
|
||||
return {
|
||||
async run() {
|
||||
return waitForStep8Ready(88, 1000);
|
||||
},
|
||||
};
|
||||
`)();
|
||||
|
||||
const result = await api.run();
|
||||
const snapshot = api.snapshot();
|
||||
|
||||
assert.deepStrictEqual(result, {
|
||||
progressed: false,
|
||||
reason: 'retry_page_recovered',
|
||||
restartCurrentStep: true,
|
||||
url: 'https://auth.openai.com/authorize',
|
||||
});
|
||||
assert.equal(snapshot.recoverCalls, 1);
|
||||
await assert.rejects(
|
||||
() => api.run(),
|
||||
/当前认证页已进入重试页/
|
||||
);
|
||||
});
|
||||
|
||||
@@ -58,6 +58,8 @@ const helperBundle = [
|
||||
extractFunction(helperSource, 'cleanupStep8NavigationListeners'),
|
||||
extractFunction(helperSource, 'rejectPendingStep8'),
|
||||
extractFunction(helperSource, 'throwIfStep8SettledOrStopped'),
|
||||
extractFunction(helperSource, 'getRunningSteps'),
|
||||
extractFunction(helperSource, 'inferStoppedRecordStep'),
|
||||
extractFunction(helperSource, 'requestStop'),
|
||||
].join('\n');
|
||||
|
||||
@@ -75,6 +77,9 @@ let autoRunTotalRuns = 3;
|
||||
let autoRunAttemptRun = 4;
|
||||
let autoRunSessionId = 99;
|
||||
const AUTO_RUN_TIMER_KIND_SCHEDULED_START = 'scheduled_start';
|
||||
const DEFAULT_STATE = {
|
||||
stepStatuses: Object.fromEntries([1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((step) => [step, 'pending'])),
|
||||
};
|
||||
const STEP8_CLICK_RETRY_DELAY_MS = 500;
|
||||
const STEP8_MAX_ROUNDS = 5;
|
||||
const STEP8_READY_WAIT_TIMEOUT_MS = 30000;
|
||||
@@ -137,6 +142,7 @@ async function addLog() {}
|
||||
async function broadcastStopToContentScripts() {}
|
||||
async function markRunningStepsStopped() {}
|
||||
async function broadcastAutoRunStatus() {}
|
||||
async function appendAndBroadcastAccountRunRecord() {}
|
||||
async function getState() {
|
||||
return { autoRunning: false };
|
||||
}
|
||||
|
||||
@@ -58,23 +58,36 @@ const bundle = [
|
||||
extractFunction('summarizeStatusBadgeEntries'),
|
||||
extractFunction('normalizeStep9StatusText'),
|
||||
extractFunction('isOAuthCallbackTimeoutFailure'),
|
||||
extractFunction('isStep10CallbackSubmittedStatus'),
|
||||
extractFunction('isStep10CallbackFailureText'),
|
||||
extractFunction('isStep10MainWaitingStatus'),
|
||||
extractFunction('isStep10MainFailureText'),
|
||||
extractFunction('isStep9FailureText'),
|
||||
extractFunction('isStep9SuccessStatus'),
|
||||
extractFunction('isStep9SuccessLikeStatus'),
|
||||
extractFunction('formatStep10StatusSummaryValue'),
|
||||
extractFunction('isStep10BrowserSwitchRequiredConflict'),
|
||||
extractFunction('getStep10BrowserSwitchRequiredMessage'),
|
||||
extractFunction('buildStep9StatusDiagnostics'),
|
||||
extractFunction('extractStep10FailureDetail'),
|
||||
extractFunction('explainStep10Failure'),
|
||||
].join('\n');
|
||||
|
||||
function createApi() {
|
||||
return new Function(`
|
||||
function isRecoverableStep9AuthFailure(text) {
|
||||
return /(?:认证失败|回调 URL 提交失败):\\s*/i.test(String(text || '').trim())
|
||||
|| /oauth flow is not pending/i.test(String(text || '').trim());
|
||||
const normalized = String(text || '').trim();
|
||||
return /(?:认证失败|回调\\s*url\\s*提交失败|回调url提交失败|提交回调失败)\\s*[::]?/i.test(normalized)
|
||||
|| /oauth flow is not pending|请更新\\s*cli\\s*proxy\\s*api\\s*或检查连接|bad request|state code error|failed to exchange authorization code for tokens|failed to save authentication tokens|unknown or expired state|invalid state|state is required|code or error is required|invalid redirect_url|provider does not match state|failed to persist oauth callback|timeout waiting for oauth callback|oauth flow timed out/i.test(normalized);
|
||||
}
|
||||
|
||||
${bundle}
|
||||
|
||||
return {
|
||||
buildStep9StatusDiagnostics,
|
||||
explainStep10Failure,
|
||||
isStep10BrowserSwitchRequiredConflict,
|
||||
getStep10BrowserSwitchRequiredMessage,
|
||||
};
|
||||
`)();
|
||||
}
|
||||
@@ -86,6 +99,7 @@ test('step 9 does not treat red success badges as exact success', () => {
|
||||
visible: true,
|
||||
text: '认证成功!',
|
||||
className: 'status-badge text-danger',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: true,
|
||||
errorVisualSummary: 'color=rgb(220, 38, 38)',
|
||||
},
|
||||
@@ -104,23 +118,118 @@ test('step 9 keeps failure state dominant when success badge and error banner co
|
||||
visible: true,
|
||||
text: '认证成功!',
|
||||
className: 'status-badge',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '回调 URL 提交失败: oauth flow is not pending',
|
||||
className: 'alert alert-danger',
|
||||
className: 'status-badge error',
|
||||
location: 'callback',
|
||||
hasErrorVisualSignal: true,
|
||||
errorVisualSummary: 'color=rgb(220, 38, 38)',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(diagnostics.hasExactSuccessVisibleBadge, true);
|
||||
assert.equal(diagnostics.hasFailureVisibleBadge, true);
|
||||
assert.equal(diagnostics.failureText, '回调 URL 提交失败: oauth flow is not pending');
|
||||
assert.equal(diagnostics.failureSource, 'callback');
|
||||
});
|
||||
|
||||
test('step 10 treats plain 认证成功 as success when no failure is visible', () => {
|
||||
const api = createApi();
|
||||
const diagnostics = api.buildStep9StatusDiagnostics(
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '认证成功',
|
||||
className: 'status-badge',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(diagnostics.hasExactSuccessVisibleBadge, true);
|
||||
assert.equal(diagnostics.exactSuccessText, '认证成功');
|
||||
});
|
||||
|
||||
test('step 10 recognizes callback accepted badge as in-progress signal', () => {
|
||||
const api = createApi();
|
||||
const diagnostics = api.buildStep9StatusDiagnostics(
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '回调 URL 已提交,等待认证中...',
|
||||
className: 'status-badge success',
|
||||
location: 'callback',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
{
|
||||
visible: true,
|
||||
text: '等待认证中...',
|
||||
className: 'status-badge',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(diagnostics.hasCallbackSubmittedBadge, true);
|
||||
assert.equal(diagnostics.callbackSubmittedText, '回调 URL 已提交,等待认证中...');
|
||||
assert.equal(diagnostics.mainWaitingText, '等待认证中...');
|
||||
assert.equal(diagnostics.hasExactSuccessVisibleBadge, false);
|
||||
});
|
||||
|
||||
test('step 10 explains callback upgrade hint with user-friendly reason', () => {
|
||||
const api = createApi();
|
||||
const explanation = api.explainStep10Failure(
|
||||
'回调 URL 提交失败: 请更新CLI Proxy API或检查连接',
|
||||
'callback'
|
||||
);
|
||||
|
||||
assert.equal(explanation.code, 'callback_submit_api_unavailable');
|
||||
assert.match(explanation.userMessage, /CLI Proxy API 版本过旧|管理接口未启动|连接异常/);
|
||||
assert.match(explanation.userMessage, /回调提交阶段/);
|
||||
});
|
||||
|
||||
test('step 10 requests browser switch when success badge and callback upgrade failure coexist', () => {
|
||||
const api = createApi();
|
||||
const diagnostics = api.buildStep9StatusDiagnostics(
|
||||
[
|
||||
{
|
||||
visible: true,
|
||||
text: '认证成功',
|
||||
className: 'status-badge success',
|
||||
location: 'main',
|
||||
hasErrorVisualSignal: false,
|
||||
errorVisualSummary: '',
|
||||
},
|
||||
{
|
||||
visible: true,
|
||||
text: '回调 URL 提交失败: 请更新CLI Proxy API或检查连接',
|
||||
className: 'status-badge error',
|
||||
location: 'callback',
|
||||
hasErrorVisualSignal: true,
|
||||
errorVisualSummary: 'color=rgb(220, 38, 38)',
|
||||
},
|
||||
],
|
||||
[],
|
||||
'page'
|
||||
);
|
||||
|
||||
assert.equal(api.isStep10BrowserSwitchRequiredConflict(diagnostics), true);
|
||||
assert.match(api.getStep10BrowserSwitchRequiredMessage(diagnostics), /更换浏览器后重新进行注册登录/);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ const source = fs.readFileSync('background/verification-flow.js', 'utf8');
|
||||
const globalScope = {};
|
||||
const api = new Function('self', `${source}; return self.MultiPageBackgroundVerificationFlow;`)(globalScope);
|
||||
|
||||
test('verification flow extends 2925 polling window', () => {
|
||||
test('verification flow keeps 2925 polling cadence in the default payload', () => {
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: { tabs: { update: async () => {} } },
|
||||
@@ -37,14 +37,55 @@ test('verification flow extends 2925 polling window', () => {
|
||||
const step4Payload = helpers.getVerificationPollPayload(4, { email: 'user@example.com', mailProvider: '2925' });
|
||||
const step8Payload = helpers.getVerificationPollPayload(8, { email: 'user@example.com', mailProvider: '2925' });
|
||||
|
||||
assert.equal(step4Payload.filterAfterTimestamp, 0);
|
||||
assert.equal(step4Payload.maxAttempts, 15);
|
||||
assert.equal(step4Payload.intervalMs, 15000);
|
||||
assert.equal(step8Payload.filterAfterTimestamp, 0);
|
||||
assert.equal(step8Payload.maxAttempts, 15);
|
||||
assert.equal(step8Payload.intervalMs, 15000);
|
||||
});
|
||||
|
||||
test('verification flow only enables 2925 target email matching in receive mode', () => {
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: { tabs: { update: async () => {} } },
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async () => ({}),
|
||||
sendToMailContentScriptResilient: async () => ({}),
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
const providePayload = helpers.getVerificationPollPayload(4, {
|
||||
email: 'user@example.com',
|
||||
mailProvider: '2925',
|
||||
mail2925Mode: 'provide',
|
||||
});
|
||||
const receivePayload = helpers.getVerificationPollPayload(4, {
|
||||
email: 'user@example.com',
|
||||
mailProvider: '2925',
|
||||
mail2925Mode: 'receive',
|
||||
});
|
||||
|
||||
assert.equal(providePayload.mail2925MatchTargetEmail, false);
|
||||
assert.equal(receivePayload.mail2925MatchTargetEmail, true);
|
||||
});
|
||||
|
||||
test('verification flow runs beforeSubmit hook before filling the code', async () => {
|
||||
const events = [];
|
||||
|
||||
@@ -111,7 +152,7 @@ test('verification flow runs beforeSubmit hook before filling the code', async (
|
||||
]);
|
||||
});
|
||||
|
||||
test('verification flow triggers 2925 mailbox cleanup only after code submission succeeds', async () => {
|
||||
test('verification flow skips 2925 mailbox preclear when using a fixed login mail window and still clears after success', async () => {
|
||||
const mailMessages = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
@@ -164,7 +205,74 @@ test('verification flow triggers 2925 mailbox cleanup only after code submission
|
||||
lastLoginCode: null,
|
||||
},
|
||||
{ provider: '2925', label: '2925 邮箱' },
|
||||
{}
|
||||
{ filterAfterTimestamp: 123456 }
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.deepStrictEqual(mailMessages, ['POLL_EMAIL', 'DELETE_ALL_EMAILS']);
|
||||
});
|
||||
|
||||
test('verification flow skips 2925 mailbox preclear when using a fixed signup mail window and still clears after success', async () => {
|
||||
const mailMessages = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'FILL_CODE') {
|
||||
return {};
|
||||
}
|
||||
if (message.type === 'RESEND_VERIFICATION_CODE') {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, message) => {
|
||||
mailMessages.push(message.type);
|
||||
if (message.type === 'POLL_EMAIL') {
|
||||
return { code: '654321', emailTimestamp: 123 };
|
||||
}
|
||||
return { ok: true, deleted: true };
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.resolveVerificationStep(
|
||||
4,
|
||||
{
|
||||
email: 'user@example.com',
|
||||
mailProvider: '2925',
|
||||
lastSignupCode: null,
|
||||
},
|
||||
{ provider: '2925', label: '2925 邮箱' },
|
||||
{
|
||||
filterAfterTimestamp: 123456,
|
||||
requestFreshCodeFirst: false,
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
@@ -301,6 +409,73 @@ test('verification flow caps mail polling timeout to the remaining oauth budget'
|
||||
assert.equal(mailPollCalls[0].payload.maxAttempts, 2);
|
||||
});
|
||||
|
||||
test('verification flow keeps 2925 mailbox polling at 15 refresh attempts even when oauth budget is smaller', async () => {
|
||||
const mailPollCalls = [];
|
||||
|
||||
const helpers = api.createVerificationFlowHelpers({
|
||||
addLog: async () => {},
|
||||
chrome: {
|
||||
tabs: {
|
||||
update: async () => {},
|
||||
},
|
||||
},
|
||||
CLOUDFLARE_TEMP_EMAIL_PROVIDER: 'cloudflare-temp-email',
|
||||
completeStepFromBackground: async () => {},
|
||||
confirmCustomVerificationStepBypassRequest: async () => ({ confirmed: true }),
|
||||
getHotmailVerificationPollConfig: () => ({}),
|
||||
getHotmailVerificationRequestTimestamp: () => 0,
|
||||
getState: async () => ({}),
|
||||
getTabId: async () => 1,
|
||||
HOTMAIL_PROVIDER: 'hotmail-api',
|
||||
isStopError: () => false,
|
||||
LUCKMAIL_PROVIDER: 'luckmail-api',
|
||||
MAIL_2925_VERIFICATION_INTERVAL_MS: 15000,
|
||||
MAIL_2925_VERIFICATION_MAX_ATTEMPTS: 15,
|
||||
pollCloudflareTempEmailVerificationCode: async () => ({}),
|
||||
pollHotmailVerificationCode: async () => ({}),
|
||||
pollLuckmailVerificationCode: async () => ({}),
|
||||
sendToContentScript: async (_source, message) => {
|
||||
if (message.type === 'FILL_CODE') {
|
||||
return {};
|
||||
}
|
||||
return {};
|
||||
},
|
||||
sendToMailContentScriptResilient: async (_mail, message, options) => {
|
||||
mailPollCalls.push({
|
||||
type: message.type,
|
||||
payload: message.payload,
|
||||
options,
|
||||
});
|
||||
return { code: '654321', emailTimestamp: 123 };
|
||||
},
|
||||
setState: async () => {},
|
||||
setStepStatus: async () => {},
|
||||
sleepWithStop: async () => {},
|
||||
throwIfStopped: () => {},
|
||||
VERIFICATION_POLL_MAX_ROUNDS: 5,
|
||||
});
|
||||
|
||||
await helpers.resolveVerificationStep(
|
||||
8,
|
||||
{
|
||||
email: 'user@example.com',
|
||||
mailProvider: '2925',
|
||||
lastLoginCode: null,
|
||||
},
|
||||
{ provider: '2925', label: '2925 邮箱' },
|
||||
{
|
||||
getRemainingTimeMs: async () => 5000,
|
||||
resendIntervalMs: 0,
|
||||
disableTimeBudgetCap: true,
|
||||
}
|
||||
);
|
||||
|
||||
const pollCall = mailPollCalls.find((entry) => entry.type === 'POLL_EMAIL');
|
||||
assert.ok(pollCall);
|
||||
assert.equal(pollCall.payload.maxAttempts, 15);
|
||||
assert.ok(pollCall.options.timeoutMs >= 250000);
|
||||
});
|
||||
|
||||
test('verification flow keeps Hotmail request timestamp filtering on the first poll', async () => {
|
||||
const pollPayloads = [];
|
||||
|
||||
|
||||
+219
-38
@@ -22,13 +22,13 @@
|
||||
- 刷新 OAuth 链接并登录
|
||||
- 轮询登录验证码
|
||||
- 自动确认 OAuth 同意页
|
||||
- 把 localhost 回调提交到 CPA 或 SUB2API
|
||||
- 把 localhost 回调提交到 CPA、SUB2API 或 Codex2API
|
||||
|
||||
## 2. 核心运行参与者
|
||||
|
||||
### 2.1 Sidepanel
|
||||
|
||||
[sidepanel/sidepanel.html](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.html) + [sidepanel/sidepanel.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.js) + [sidepanel/update-service.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/update-service.js)
|
||||
[sidepanel/sidepanel.html](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.html) + [sidepanel/sidepanel.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/sidepanel.js) + [sidepanel/account-pool-ui.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/account-pool-ui.js) + [sidepanel/update-service.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/update-service.js) + [sidepanel/contribution-content-update-service.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/contribution-content-update-service.js)
|
||||
|
||||
职责:
|
||||
|
||||
@@ -37,9 +37,13 @@
|
||||
- 向后台发送命令
|
||||
- 接收后台广播并更新 UI
|
||||
- 动态渲染步骤列表
|
||||
- 顶部提供一个临时 `贡献` 按钮,用于直接打开账号贡献上传页
|
||||
- 管理顶部“贡献”按钮与贡献模式主面板;贡献模式本身是 sidepanel 的运行态 UI 模式,不是新的 `panelMode` 来源
|
||||
- 在贡献模式下复用同一套主自动流程启动,并在面板内展示贡献链路的 `OAUTH / 回调 / 总状态` 三块实时状态
|
||||
- 在顶部“贡献/使用”按钮下方展示一个非强制的内容更新轻提示;提示来源于 `apikey.qzz.io` 的公开公告 / 教程摘要,用户关闭后仅对当前 `promptVersion` 静默,下次内容版本变化后会重新出现
|
||||
- 在 sidepanel 初始化和点击“自动”按钮前刷新一次贡献站公开内容摘要;如果刷新失败,不阻塞主自动流程
|
||||
- 在日志区通过“记录”按钮打开独立的邮箱记录覆盖层,并展示成功/失败/停止/重试统计与分页列表
|
||||
- 查询 GitHub Releases 并展示更新卡片;当前更新服务会区分 `Pro` 与 legacy `v` 两个版本族,排序时优先保持版本族语义一致,同时会在读取缓存后重新排序,避免旧缓存把 `v` 版本误显示为比 `Pro` 更新
|
||||
- 为 Hotmail / 2925 账号池复用同一套“添加账号 / 取消添加 / 批量导入 / 收起列表”表单交互;共享的显隐控制放在 `sidepanel/account-pool-ui.js`,各自 manager 只保留 provider 相关字段校验与业务操作
|
||||
|
||||
### 2.2 Background Service Worker
|
||||
|
||||
@@ -119,6 +123,15 @@
|
||||
保存运行态:
|
||||
|
||||
- 当前步骤状态
|
||||
- 当前 sidepanel UI 模式 `contributionMode`
|
||||
- 贡献 OAuth 会话字段:
|
||||
- `contributionSessionId`
|
||||
- `contributionAuthUrl`
|
||||
- `contributionAuthState`
|
||||
- `contributionCallbackUrl`
|
||||
- `contributionStatus`
|
||||
- `contributionStatusMessage`
|
||||
- `contributionLastPollAt`
|
||||
- OAuth 链接
|
||||
- 当前邮箱 / 密码
|
||||
- 第 8 步固定的验证码页显示邮箱 `step8VerificationTargetEmail`
|
||||
@@ -128,6 +141,7 @@
|
||||
- 标签注册表
|
||||
- 最近打开的来源地址
|
||||
- LuckMail 当前运行时选择
|
||||
- 2925 当前选中的账号 ID `currentMail2925AccountId`(运行时会同步到持久配置,用于重开浏览器后恢复同一个号池账号)
|
||||
|
||||
补充:
|
||||
|
||||
@@ -138,8 +152,12 @@
|
||||
保存持久配置与账号运行历史:
|
||||
|
||||
- CPA / SUB2API 配置
|
||||
- Codex2API 配置
|
||||
- 邮箱 provider 配置
|
||||
- Hotmail 账号池
|
||||
- 2925 账号池
|
||||
- 2925 是否启用号池模式 `mail2925UseAccountPool`
|
||||
- 2925 当前选中的号池账号 ID `currentMail2925AccountId`
|
||||
- Cloudflare / Temp Email 设置
|
||||
- iCloud 相关偏好
|
||||
- LuckMail API 配置
|
||||
@@ -147,6 +165,11 @@
|
||||
- 账号运行历史 `accountRunHistory`(以邮箱为主键,保存该邮箱最近一次状态:成功/失败/停止)
|
||||
- 账号运行历史本地同步开关与 helper 地址
|
||||
|
||||
注意:
|
||||
|
||||
- `contributionMode` 不属于持久配置,也不参与导入/导出;它只存在于运行态
|
||||
- `panelMode` 当前表示 `cpa | sub2api | codex2api` 三个来源;贡献模式仍不是新的来源枚举
|
||||
|
||||
当启用了独立的账号运行历史本地同步配置时,账号运行历史会通过 [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py) 整体同步写入 `data/account-run-history.json` 快照文件,便于开发者直接查看完整记录。
|
||||
这条配置链路独立于 `mailProvider` 和 Hotmail 的接码模式。
|
||||
|
||||
@@ -161,22 +184,53 @@
|
||||
- `ICLOUD_LOGIN_REQUIRED`
|
||||
- `ICLOUD_ALIASES_CHANGED`
|
||||
|
||||
### 4.4 临时贡献入口
|
||||
### 4.4 贡献模式链路
|
||||
|
||||
当前 `dev` 分支里的贡献能力先以“临时上传入口”形式提供,而不是完整接入主自动链。
|
||||
|
||||
流程:
|
||||
贡献模式的目标不是新增一个 provider,而是在 sidepanel 内开启一套“贡献账号”专用 UI,并把公开贡献服务并进原有 10 步主链:
|
||||
|
||||
1. 用户点击顶部 `贡献`
|
||||
2. 扩展调用统一的新标签页打开逻辑
|
||||
3. 浏览器直接打开 `https://apikey.qzz.io/`
|
||||
2. sidepanel 直接通过 `SET_CONTRIBUTION_MODE` 切换运行态,不再弹确认窗口
|
||||
3. 进入贡献模式后会强制 `panelMode = cpa`,并临时清空运行态 `customPassword`、禁用运行态账号记录快照同步
|
||||
4. sidepanel 隐藏 CPA 管理地址、管理密钥、SUB2API 配置、Codex2API 配置、自定义密码、本地同步等普通模式配置,并禁用来源选择、配置菜单和记录入口
|
||||
5. 用户点击 `开始贡献` 后,不再单独走一条旁路 OAuth 流程,而是直接复用顶部 `自动` 的同一套主自动流
|
||||
6. 步骤 1~6 仍按原来的注册自动化执行
|
||||
7. 当主流程进入步骤 7 时,后台改为调用公开接口 `POST https://apikey.qzz.io/oauth/api/start` 申请贡献登录地址,而不是去 CPA / SUB2API / Codex2API 来源刷新 OAuth
|
||||
8. 步骤 7 拿到 `session_id / auth_url / state` 后,继续沿用原有登录链路进入授权页
|
||||
9. 步骤 9 仍负责捕获 localhost callback;贡献模式下后台也会持续监听导航变化,必要时提前兼容处理 callback
|
||||
10. 步骤 10 在贡献模式下不再打开 CPA 管理页,而是围绕公开贡献会话做 callback 提交兼容和最终状态确认;当状态进入 `auto_approved / auto_rejected / manual_review_required / expired / error` 时结束
|
||||
11. 当前服务端如果返回“无需手动提交 callback”,扩展会把它视为兼容成功态,而不是报错
|
||||
12. `已有认证文件?前往上传` 继续打开 `https://apikey.qzz.io/`
|
||||
13. `退出贡献模式` 会清理贡献会话相关运行态,`重置` 只重置主流程状态,不会自动退出贡献模式
|
||||
|
||||
边界:
|
||||
这条链路的关键边界是:
|
||||
|
||||
- 当前这颗按钮不参与 1~10 步主流程
|
||||
- 当前这颗按钮不调用后台管理接口
|
||||
- 当前这颗按钮不保存额外运行态
|
||||
- 当步骤正在运行或自动流程处于锁定状态时,按钮会禁用,避免和主流程冲突
|
||||
- 扩展会调用公开贡献 API,但这条调用被折叠进主 10 步自动链,而不是额外分出一条独立“贡献 OAuth 小流程”
|
||||
- 扩展不会保存 token
|
||||
- 扩展不会持有任何管理密钥
|
||||
- 扩展代码中不能直接调用 `/v0/management/*`
|
||||
- 扩展不会直接访问生产管理面或生产 auth-dir
|
||||
- 安全边界仍然在服务端;扩展只负责公开 OAuth 贡献前端流程
|
||||
- 退出贡献模式后,要恢复普通模式 UI,并恢复持久配置中的自定义密码/本地同步偏好
|
||||
|
||||
### 4.5 贡献内容更新提示链路
|
||||
|
||||
这条链路用于把 `apikey.qzz.io` 上的公开公告 / 使用教程更新,映射成 sidepanel 顶部“贡献/使用”按钮下方的轻提示。
|
||||
|
||||
1. 贡献站公开暴露 `GET https://apikey.qzz.io/api/content-summary`
|
||||
2. 接口返回 `announcement / tutorial` 两类内容的可见状态、更新时间,以及聚合后的 `promptVersion`
|
||||
3. `sidepanel/contribution-content-update-service.js` 负责拉取这个摘要,并在本地缓存最近一次可用结果
|
||||
4. `sidepanel.js` 在 sidepanel 初始化时会先拉一次摘要;如果当前 `promptVersion` 没被本地关闭过,就显示提示
|
||||
5. 用户点击提示右侧 `X` 后,只会把当前 `promptVersion` 记入 `localStorage`,不会永久关闭整个能力
|
||||
6. 当公告或教程再次更新,服务端返回新的 `promptVersion`,提示会重新出现
|
||||
7. 用户点击“自动”按钮时,sidepanel 会在真正启动自动流程前再刷新一次摘要,尽量让提示状态更及时
|
||||
8. 如果这次刷新失败,sidepanel 只记录警告并继续自动流程,不会因为提示链路故障阻塞主功能
|
||||
|
||||
这条链路的关键边界是:
|
||||
|
||||
- 它只消费公开摘要接口,不接触任何管理接口
|
||||
- 它只影响顶部轻提示,不改变贡献模式主状态机
|
||||
- 它使用 `localStorage` 做缓存和关闭版本记录,而不是写入 `chrome.storage.session/local`
|
||||
- 它是辅助提示链路,不允许反向阻塞自动运行
|
||||
|
||||
## 5. 内容脚本通信链路
|
||||
|
||||
@@ -257,7 +311,7 @@
|
||||
5. 内容脚本先上报 Step 3 完成信号
|
||||
6. 上报完成后再异步点击提交,避免页面跳转打断响应通道
|
||||
7. 延迟提交真正触发前会再次检查 Stop 状态,避免用户已停止时页面仍继续自动提交
|
||||
8. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路
|
||||
8. 后台在真正确认 Step 3 完成前,会额外检查提交后是否切换页面;如果出现认证页 `Try again / 重试` 页面,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续后续链路
|
||||
|
||||
### Step 4 / Step 8
|
||||
|
||||
@@ -278,16 +332,20 @@
|
||||
|
||||
补充行为:
|
||||
|
||||
- `2925` provider 会拉长单轮轮询窗口,并关闭 Step 4 / 8 的自动重发间隔,减少因邮件延迟过早判负。
|
||||
- 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 不再依赖时间窗,而是以“当前收件箱快照 + 刷新后新增邮件”作为优先匹配依据。
|
||||
- `2925` provider 会关闭 Step 4 / 8 的自动重发间隔 25 秒节流;每次“重新发送验证码”之间,会在邮箱页内部执行一轮固定 15 次的刷新轮询,不再因 OAuth 剩余时间预算而缩短。
|
||||
- 当 provider 为 `2925` 时,Step 4 会优先直接打开当前 2925 邮箱页,并先比对页面顶部显示的邮箱地址是否与当前目标邮箱一致:如果一致,就直接复用当前已登录页面;如果不一致且启用了 2925 账号池,则会先清理 cookie 再登录当前选中的账号;如果不一致且未启用账号池,则直接复用现有停止逻辑结束流程。Step 8 不再额外承接这套登录态处理。
|
||||
- `2925` 在执行自动登录后,如果登录页因为跳转或重载导致原内容脚本通信中断,后台不会立刻判失败;而是会等待当前标签页重新加载完成、重新确认内容脚本就绪后,再继续确认是否已经进入收件箱。这段登录恢复窗口当前按 2 分钟控制。
|
||||
- 普通邮箱仍会携带 `filterAfterTimestamp` 做时间窗筛选;`2925` 在 Step 4 / Step 8 会固定使用“步骤开始时间向前回看 10 分钟”的时间窗,不再做“新旧邮件快照差集”比较,而是每次刷新后直接遍历当前列表中落在该固定时间窗内的匹配邮件。
|
||||
- 自动重新发送验证码次数现在使用 sidepanel 里的单一“验证码重发”配置;普通邮箱仍按 25 秒间隔节流,Hotmail / 2925 不走这个 25 秒间隔。Step 4 若启用先请求新验证码,会先消耗一次当前步骤的自动重发次数。
|
||||
- 验证码提交重试上限当前为 7 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。
|
||||
- `2925` 内容脚本会把每次成功读到的目标邮件立即删除;同一验证码步骤启动后,试过的验证码会按“步骤 ID + 启动时间”隔离缓存,不会在本次步骤里重复提交。
|
||||
- `2925` 在连续 3 轮未发现新增邮件后,会回退检查列表里现存的匹配邮件;每轮轮询之间只刷新 1 次收件箱列表。
|
||||
- `2925` 当前不再对邮件里的收件邮箱做比对,只要邮件内容命中 ChatGPT / OpenAI 验证码过滤条件,就会尝试该邮件。
|
||||
- 验证码提交重试上限当前为 15 次;页面明确拒绝验证码时,会在上限内继续拉取新验证码并重提。
|
||||
- `2925` 内容脚本会把每一封实际打开检测的邮件立即删除;同一验证码步骤启动后,试过的验证码会按“步骤 ID + 启动时间”隔离缓存,不会在本次步骤里重复提交;如果再次遇到相同验证码,对应邮件也会在读取后立即删除,避免后续反复打开。
|
||||
- `2925` 在 provide 模式下仍保持宽松匹配:只要邮件内容命中 ChatGPT / OpenAI 验证码过滤条件,就会尝试该邮件。
|
||||
- `2925` 在 receive 模式下会恢复“弱目标邮箱匹配”:只有当邮件里显式出现了其他收件邮箱时才会跳过;如果邮件里没有明确写出邮箱,仍允许继续尝试该验证码。
|
||||
- 手动点击 Step 4 重新执行时,后台会先检查 `signup-page` 认证页标签是否仍然存在;如果步骤 1 / 2 打开的认证页已经关闭,就会直接提示“请先执行步骤 1 或步骤 2,确保认证页仍然打开并停留在验证码页”,不会先重置后续步骤再报技术错误。
|
||||
- 当验证码最终提交成功后,后台会异步向 2925 邮箱页发送 `DELETE_ALL_EMAILS`,执行“全选 + 删除”清理剩余邮件,不阻塞主流程。
|
||||
- 如果 `2925` 邮箱页在轮询期间出现“子邮箱已达上限邮箱”,后台会记录当前时间,把当前 2925 账号禁用 24 小时,自动切到下一个可用账号并完成登录,然后直接报错结束当前尝试;如果 Auto 开启了自动重试,现有控制器会按原逻辑进入下一次尝试。
|
||||
|
||||
- Auto 模式下,如果 Step 4 当前轮失败,后台会沿用当前邮箱回到 Step 1 重新开始当前轮,而不是立刻换邮箱开新尝试。
|
||||
- Auto 模式下,普通 Step 4 失败仍会沿用当前邮箱回到 Step 1 重新开始当前轮;但若失败原因是 2925 主动要求“结束当前尝试”,则不会再回到 Step 1 重开,而是直接把错误抛给自动重试控制器。
|
||||
|
||||
### Step 5
|
||||
|
||||
@@ -324,14 +382,22 @@
|
||||
|
||||
流程:
|
||||
|
||||
1. 通过 CPA / SUB2API 刷新 OAuth 地址
|
||||
1. 按当前来源刷新 OAuth 地址:
|
||||
- CPA:打开管理页并读取 OAuth 地址
|
||||
- SUB2API:打开后台并生成 OAuth 地址
|
||||
- Codex2API:直接调用后台协议 `/api/admin/oauth/generate-auth-url`
|
||||
2. 打开最新 OAuth 链接
|
||||
3. 登录
|
||||
3. 登录;如果进入密码页且当前有密码,则填写并提交密码;如果当前没有密码但检测到一次性验证码入口,则直接切换到一次性验证码登录
|
||||
4. 确保真正进入验证码页
|
||||
5. 如果未进入验证码页,则按可恢复逻辑最多重试 3 次;但一旦当前失败已经明确进入 `https://auth.openai.com/add-phone`,则立即退出步骤 7 内部重试,不再继续第 2 / 3 次尝试
|
||||
6. 自动运行一旦进入步骤 7 之后的链路,若后续步骤报错且认证页未进入 `https://auth.openai.com/add-phone`,则统一回到步骤 7 重新开始授权流程
|
||||
7. CPA 回调阶段固定为步骤 9,不再支持“第七步回调”跳过步骤 7/8
|
||||
|
||||
贡献模式补充:
|
||||
|
||||
- 贡献模式下,步骤 7 不再从 CPA / SUB2API / Codex2API 来源刷新 OAuth,而是直接调用公开贡献接口 `/oauth/api/start`
|
||||
- 贡献接口返回的 `auth_url` 会写回运行态 `oauthUrl`,后续步骤 7 / 8 / 9 继续复用现有授权链路
|
||||
|
||||
### Step 8
|
||||
|
||||
文件:
|
||||
@@ -351,7 +417,8 @@
|
||||
|
||||
补充:
|
||||
|
||||
- 对 `2925` 而言,Step 8 不再依赖验证码页显示邮箱做收件匹配,而是直接测试所有命中 ChatGPT / OpenAI 过滤条件的邮件。
|
||||
- 对 `2925 provide` 而言,Step 8 仍不依赖验证码页显示邮箱做收件匹配,而是直接测试所有命中 ChatGPT / OpenAI 过滤条件的邮件。
|
||||
- 对 `2925 receive` 而言,Step 8 会把当前目标注册邮箱一并传给 2925 内容脚本;只有当邮件里显式写出了其他邮箱时才会跳过,从而在不破坏历史兼容性的前提下,尽量降低误收验证码的概率。
|
||||
|
||||
### Step 9
|
||||
|
||||
@@ -375,38 +442,52 @@
|
||||
流程:
|
||||
|
||||
1. 校验 localhost callback 是否有效
|
||||
2. 判断是 CPA 还是 SUB2API
|
||||
3. 打开相应后台
|
||||
4. 提交回调地址
|
||||
2. 判断当前来源是 CPA、SUB2API 还是 Codex2API
|
||||
3. CPA / SUB2API 打开相应后台;Codex2API 直接走协议分支,不打开后台页面
|
||||
4. 提交回调地址或等价的授权码交换请求
|
||||
5. 仅当出现精确成功徽标,且该徽标不是红色/错误态、页面上也没有同时可见的失败提示时,才判定成功
|
||||
6. 识别 `认证失败:*`、`认证失败: timeout of 30000ms exceeded`、`回调 URL 提交失败: oauth flow is not pending` 等失败提示并立即报错
|
||||
7. 完成平台侧验证
|
||||
8. 追加账号运行历史成功记录
|
||||
9. 做成功后的清理与标记
|
||||
|
||||
Codex2API 补充:
|
||||
|
||||
- 步骤 7 直接调用 `POST /api/admin/oauth/generate-auth-url` 获取 `auth_url / session_id`
|
||||
- 授权页仍沿用现有 OpenAI 登录、验证码、OAuth 同意页与 localhost callback 主链
|
||||
- 步骤 10 直接调用 `POST /api/admin/oauth/exchange-code`,用 callback 中的 `code / state` 完成账号创建
|
||||
- Codex2API 这条来源不新增 panel content script,也不依赖“添加账号 -> OAuth 授权 -> 生成授权链接”页面按钮 DOM
|
||||
|
||||
贡献模式补充:
|
||||
|
||||
- 贡献模式下,步骤 10 不再打开 CPA 管理页
|
||||
- 步骤 10 会先尝试提交已捕获的 callback URL,随后轮询公开贡献状态,直到进入最终态
|
||||
- `auto_approved` 与 `manual_review_required` 视为主流程完成;`auto_rejected / expired / error` 视为当前轮失败
|
||||
|
||||
## 7. 邮箱与 provider 链路
|
||||
|
||||
### 7.1 2026-04-17 补充:Gmail / 2925 统一别名邮箱链路
|
||||
|
||||
本轮将 Gmail 与 2925 的注册邮箱逻辑统一收敛为“共享别名邮箱链路”:
|
||||
本轮将 Gmail 与 `2925 + provide` 的注册邮箱逻辑统一收敛为“共享别名邮箱链路”:
|
||||
|
||||
- 两者都先填写“基邮箱”
|
||||
- Gmail:`name@gmail.com`
|
||||
- 2925:`name@2925.com`
|
||||
- 2925(仅 provide 模式):`name@2925.com`
|
||||
- 两者都允许两种入口:
|
||||
- 点击侧边栏按钮自动生成完整注册邮箱
|
||||
- 直接在“注册邮箱”输入框中手动填写完整邮箱
|
||||
- 当 `mailProvider = 2925` 且 `mail2925Mode = receive` 时,不再走别名基邮箱链路,而是回退到普通“邮箱生成 / 手动填写注册邮箱”路线;2925 仅负责后续收信与登录态管理
|
||||
|
||||
当前行为约定:
|
||||
|
||||
1. sidepanel 展示“别名基邮箱”输入框,并根据当前 provider 显示对应文案
|
||||
1. sidepanel 会根据当前 provider 与 `mail2925Mode` 决定是否展示“别名基邮箱”输入框
|
||||
2. 点击 `获取 / 生成` 时:
|
||||
- Gmail 生成 `name+tag@gmail.com`
|
||||
- 2925 生成 `name123456@2925.com`
|
||||
- 2925 仅在 provide 模式下生成 `name123456@2925.com`
|
||||
3. Step 2 / Step 3 进入注册流程前,会先判断当前 `state.email`
|
||||
- 如果已经是与当前基邮箱兼容的完整邮箱,则直接复用
|
||||
- 如果为空或不兼容,则按当前 provider 重新生成
|
||||
4. 保存或执行 Step 3 时,如果手动填写的完整邮箱与当前 Gmail / 2925 基邮箱不兼容,sidepanel 会直接拦截
|
||||
- 如果为空或不兼容,则按当前 provider / mode 重新生成
|
||||
4. 保存或执行 Step 3 时,如果手动填写的完整邮箱与当前 Gmail / 2925 provide 基邮箱不兼容,sidepanel 会直接拦截;`2925 receive` 不参与这条兼容性约束
|
||||
5. auto-run fresh attempt reset 时,会保留:
|
||||
- `gmailBaseEmail`
|
||||
- `mail2925BaseEmail`
|
||||
@@ -446,6 +527,8 @@
|
||||
- [hotmail-utils.js](c:/Users/projectf/Downloads/codex注册扩展/hotmail-utils.js)
|
||||
- [microsoft-email.js](c:/Users/projectf/Downloads/codex注册扩展/microsoft-email.js)
|
||||
- [scripts/hotmail_helper.py](c:/Users/projectf/Downloads/codex注册扩展/scripts/hotmail_helper.py)
|
||||
- [sidepanel/hotmail-manager.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/hotmail-manager.js)
|
||||
- [sidepanel/account-pool-ui.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/account-pool-ui.js)
|
||||
|
||||
模式:
|
||||
|
||||
@@ -456,6 +539,7 @@
|
||||
|
||||
- 本地 helper 除了收信与验证码读取,还提供邮箱记录 JSON 快照同步接口。
|
||||
- 账号运行历史本地同步由独立配置控制,不再绑定 Hotmail 的本地助手模式。
|
||||
- sidepanel 中 Hotmail 账号池的新增表单默认收起,头部通过共享按钮切换“添加账号 / 取消添加”;表单显隐、按钮文案切换、清空与聚焦都复用 `sidepanel/account-pool-ui.js`,不在 Hotmail manager 内重复实现一套。
|
||||
|
||||
### 7.3 LuckMail
|
||||
|
||||
@@ -481,7 +565,91 @@
|
||||
- `https://webmail.vip.163.com/*`
|
||||
- `https://mail.126.com/*`
|
||||
|
||||
### 7.5 iCloud
|
||||
### 7.5 2925 账号池
|
||||
|
||||
组成:
|
||||
|
||||
- [mail2925-utils.js](c:/Users/projectf/Downloads/codex注册扩展/mail2925-utils.js)
|
||||
- [background/mail-2925-session.js](c:/Users/projectf/Downloads/codex注册扩展/background/mail-2925-session.js)
|
||||
- [sidepanel/mail-2925-manager.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/mail-2925-manager.js)
|
||||
- [sidepanel/account-pool-ui.js](c:/Users/projectf/Downloads/codex注册扩展/sidepanel/account-pool-ui.js)
|
||||
|
||||
职责:
|
||||
|
||||
- `mail2925-utils.js`
|
||||
统一承接 2925 账号池的归一化、冷却期判断、可用账号挑选、导入格式解析与列表更新。
|
||||
- `background/mail-2925-session.js`
|
||||
统一承接 2925 账号池的持久化、当前账号切换、cookie 清理登出、网页登录态确认、自动登录,以及命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
|
||||
- `sidepanel/mail-2925-manager.js`
|
||||
负责 2925 账号池的新增、导入、切换、手动登录、禁用、清冷却与删除。
|
||||
- `sidepanel/account-pool-ui.js`
|
||||
负责 Hotmail / 2925 账号池共用的新增表单显隐、头部按钮文案切换、清空表单与首字段聚焦;2925 manager 不再单独维护另一套表单开关状态机。
|
||||
|
||||
链路:
|
||||
|
||||
1. 用户在 sidepanel 的 2925 账号池中保存 `email / password`
|
||||
2. sidepanel 中会单独展示 `提供邮箱 / 接收邮箱` 模式切换,以及独立的 `2925 号池` 开关 / 当前账号下拉框;这样即使切到 receive 模式,账号池设置也不会被别名基邮箱行一起隐藏
|
||||
3. 只有当 sidepanel 中的 `mail2925UseAccountPool` 开关开启时,provide 模式下的别名基邮箱才会优先取当前账号池选中的 2925 账号邮箱;关闭时会回退到原来的手填 `mail2925BaseEmail`
|
||||
4. 手动点击 `登录` 或自动流程进入 Step 4 前,后台会先打开当前 2925 邮箱页,并读取页面顶部当前邮箱地址:如果仍停留在收件箱且顶部邮箱与目标邮箱一致,则直接复用;如果顶部邮箱不一致且启用了号池模式,则先清理 cookie 后登录当前选中的账号;如果顶部邮箱不一致且未启用号池模式,则直接调用现有停止逻辑结束流程;如果页面跳到登录页,则仍然只有号池模式开启时才自动登录
|
||||
5. 一旦轮询期间出现“子邮箱已达上限邮箱”,后台会先判断是否启用了号池模式:若已启用且还有下一个可用账号,则把当前账号禁用 24 小时并自动切到下一个账号重新登录;若未启用,则直接调用现有停止逻辑结束流程
|
||||
6. 如果登录页已经识别到账号密码输入框,内容脚本会在填完账号密码后额外等待 1 秒再点击登录;若点击登录后 40 秒内仍未进入收件箱,且当前正处于自动运行中,则后台会直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态;这类情况常见于图片验证、行为验证或其他阻断登录的中间页
|
||||
7. 如果没有下一个可用账号,或当前未启用号池模式,则不会继续消耗自动重试次数,而是直接复用现有 `requestStop()` 停止链路,把整个自动流程停成和用户手动点击“停止”一致的状态
|
||||
8. sidepanel 中 2925 账号池的新增表单也走与 Hotmail 相同的共享交互:默认收起,头部按钮切换“添加账号 / 取消添加”,操作行右侧提供“批量导入”,保存成功后自动收起并清空。
|
||||
9. 当 2925 号池模式开启时,当前选中的号池邮箱会同步回写到同一个 `mail2925BaseEmail` 字段;因此用户切换号池账号后,即使再次关闭号池模式,也会直接沿用刚才选中的邮箱作为手动基邮箱,无需重新输入。
|
||||
|
||||
### 7.5.1 2925 双模式维护约定
|
||||
|
||||
这是后续维护 `2925` 时最容易被重新打散的一段链路,建议按下面的职责边界理解:
|
||||
|
||||
- UI 层:
|
||||
- `sidepanel/sidepanel.html`
|
||||
- 负责展示 `提供邮箱 / 接收邮箱`
|
||||
- 负责展示独立的 `2925 号池` 配置行
|
||||
- 不负责决定“2925 是否参与别名邮箱生成”
|
||||
- `sidepanel/sidepanel.js`
|
||||
- 只负责把当前 `mail2925Mode`、号池开关、当前账号选择同步到 state / runtime message
|
||||
- 只负责用共享规则决定当前文案、显隐和前端校验
|
||||
- 不应再次复制一套“provide / receive 对应什么行为”的业务判断
|
||||
|
||||
- 共享规则层:
|
||||
- `managed-alias-utils.js`
|
||||
- 负责 `mail2925Mode` 归一化
|
||||
- 负责判断 `2925` 当前是否属于“别名邮箱 provider”
|
||||
- 负责保证 `2925` 只有在 `provide` 模式下才进入共享别名邮箱链路
|
||||
- 如果未来要新增第三种模式,应该先改这里,再改 sidepanel / background 接线
|
||||
|
||||
- 注册邮箱生成层:
|
||||
- `background/generated-email-helpers.js`
|
||||
- 只负责根据当前 provider / mode 选择“共享别名邮箱”还是“普通邮箱生成器”
|
||||
- 不负责定义 `provide / receive` 的语义本身
|
||||
- `background/signup-flow-helpers.js`
|
||||
- 只负责真正提交前的“复用已有邮箱 / 重新生成邮箱”
|
||||
- 这里看到的 `isGeneratedAliasProvider(state)` 已经包含 `mail2925Mode` 语义,不应该再手写 `state.mail2925Mode === 'provide'`
|
||||
|
||||
- 收信与验证码层:
|
||||
- `background/verification-flow.js`
|
||||
- 负责决定是否给 2925 内容脚本下发 `mail2925MatchTargetEmail`
|
||||
- 当前约定:仅 `receive` 模式开启弱目标邮箱匹配
|
||||
- `content/mail-2925.js`
|
||||
- 负责真正执行“弱目标邮箱匹配”
|
||||
- 当前约定:只有邮件里显式出现了其他邮箱才跳过;若邮件里没有写邮箱,仍允许继续尝试
|
||||
- 这样做的目的,是在不破坏历史兼容性的前提下,降低 receive 模式误收验证码的概率
|
||||
|
||||
- 账号池与登录态层:
|
||||
- `background/mail-2925-session.js`
|
||||
- 不关心 `provide / receive` 哪个负责生成注册邮箱
|
||||
- 只关心当前是否允许自动登录、是否需要切号、是否命中上限、是否需要停止自动流程
|
||||
- 因此后续如果只改别名生成语义,不应把这层重新卷入 provider 判定
|
||||
|
||||
后续如果再改 `2925`,建议最少按下面顺序自检:
|
||||
|
||||
1. `managed-alias-utils.js` 是否仍是 `provide / receive` 的唯一共享语义来源
|
||||
2. `sidepanel/sidepanel.js` 是否只做接线,没有复制业务规则
|
||||
3. `background/generated-email-helpers.js` / `background/signup-flow-helpers.js` 是否仍然只做调度,不重复写模式语义
|
||||
4. `background/verification-flow.js` 与 `content/mail-2925.js` 的“弱目标邮箱匹配”是否保持同步
|
||||
5. `项目文件结构说明.md` 与当前文件是否已同步更新
|
||||
|
||||
### 7.6 iCloud
|
||||
|
||||
组成:
|
||||
|
||||
@@ -540,6 +708,10 @@
|
||||
5. Step 4 / 7 的验证码流
|
||||
6. 成功收尾逻辑
|
||||
|
||||
如果来源本身提供稳定协议接口,还必须额外判断:
|
||||
|
||||
7. 是否可以不打开来源后台页面,直接把步骤 7 / 10 收敛到协议分支
|
||||
|
||||
### 新增配置项
|
||||
|
||||
必须同时检查:
|
||||
@@ -564,8 +736,17 @@
|
||||
## 2026-04 链路补充:认证页共享恢复
|
||||
|
||||
- 新增共享恢复层:`content/auth-page-recovery.js`。
|
||||
- Step 4 在等待注册验证码页时,如果命中认证页 `Try again / 重试` 页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续回到密码页重提和验证码页确认流程。
|
||||
- Step 7 在识别到登录超时报错页时,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复当前页面;若仍未恢复,则按原有可恢复失败逻辑重跑 Step 7。
|
||||
- Step 4 在等待注册验证码页时,如果命中认证页 `Try again / 重试` 页,或 `/email-verification` 上的 `405 / Route Error` 重试页,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再继续回到密码页重提和验证码页确认流程。
|
||||
- 但如果 Step 4 的认证重试页正文中出现 `user_already_exists`,则会直接视为“当前用户已存在”:不点击 `重试`,不再回到步骤 1 重开当前轮,而是立即结束当前轮;开启自动重试时直接进入下一轮。
|
||||
- Step 7 在识别到登录超时报错页时,会先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复当前页面;恢复成功后会优先按当前页面状态继续当前登录流程,例如直接续跑邮箱页或密码页;只有仍未恢复到可继续状态时,才按原有可恢复失败逻辑重跑 Step 7。
|
||||
- Step 7 在首次识别到登录验证码页后,不会立刻把步骤判定为成功;还会额外做一轮“收尾确认”,确保页面稳定停留在登录验证码页。如果只是短暂进入验证码页、随后又掉进登录重试页,则会先走共享恢复逻辑,再按既有可恢复失败逻辑重跑 Step 7。
|
||||
- Step 7 的这轮收尾确认是主要责任边界;Step 8 默认建立在“登录验证码页已经由 Step 7 稳定确认”的前提上,只在后台入口保留防御性回退判断,不替代 Step 7 收尾。
|
||||
- Step 8 如果发现认证页已经进入登录超时报错/重试页,会直接报错并回到 Step 7 重新开始,而不是在 Step 8 内部点击 `重试`。
|
||||
- Step 8 的登录重试页判定也覆盖 `/email-verification` 上的 `405 / Route Error`,避免这类页面被误当成普通未知页。
|
||||
- 任意认证页重试页如果正文中出现 `max_check_attempts`,会被视为 Cloudflare 风控触发:后台立刻完全停止流程,侧边栏会复用现有确认弹窗提示等待 15~30 分钟后再试,避免继续刷新或反复重试加重风控,确认按钮显示为“我知道了”。
|
||||
- Step 9 在点击 OAuth 同意页 `继续` 后,会额外检查是否进入认证页重试页;若命中则先通过共享恢复逻辑最多自动点击 5 次 `重试` 尝试恢复,再重新执行当前轮的 `继续` 点击。
|
||||
- Step 9 在点击 OAuth 同意页 `继续` 后,会持续等待页面跳转;若点击后命中认证页重试页,则直接报错,不会在 Step 9 内部点击 `重试`。
|
||||
## 2026-04-21 2925 邮件时间窗补充
|
||||
|
||||
- `2925` 在 Step 4 / Step 8 现在会携带固定的步骤开始时间窗口,实际筛选下限为“步骤开始时间向前回看 10 分钟”。
|
||||
- 为了保留这段固定时间窗内已经到达的验证码邮件,后台不再在轮询开始前预先清空 2925 邮箱。
|
||||
- `2925` 验证码最终提交成功后,后台仍会异步发送 `DELETE_ALL_EMAILS` 做收尾清理。
|
||||
|
||||
+30
-6
@@ -82,6 +82,14 @@
|
||||
5. 是否需要成功收尾逻辑
|
||||
6. 是否需要 README 与完整链路文档更新
|
||||
|
||||
补充约定:
|
||||
|
||||
- 如果新增来源本身已经提供稳定的后台协议接口,可以直接走协议分支接入:
|
||||
- 步骤 7 通过 `background/panel-bridge.js` 生成 `auth_url`
|
||||
- 步骤 10 通过 `background/steps/platform-verify.js` 直接提交 localhost callback
|
||||
- 这类来源优先复用现有 OpenAI 授权页与 localhost callback 主链,不要为了“看起来统一”再额外新增一套页面 DOM 自动点击内容脚本。
|
||||
- 只有当目标来源没有可用协议接口、必须依赖后台页面按钮时,才新增对应的 panel content script。
|
||||
|
||||
### 3.2.1 共享别名邮箱逻辑补充
|
||||
|
||||
当 Gmail / 2925 这类“既影响注册邮箱生成,又影响 sidepanel 表单行为”的 provider 发生变化时,必须优先检查是否应落入共享层,而不是继续把规则分散写在:
|
||||
@@ -93,9 +101,12 @@
|
||||
当前约定:
|
||||
|
||||
- Gmail / 2925 的基邮箱解析、兼容性判断、别名生成、UI 文案优先收敛到 `managed-alias-utils.js`
|
||||
- `2925` 是否参与“共享别名邮箱链路”必须由共享层统一判断;当前只有 `mail2925Mode = provide` 才允许把 `2925` 视为别名邮箱 provider,`receive` 不能在 sidepanel / step / provider 分支里各自偷写一套判断
|
||||
- `background/generated-email-helpers.js` 只负责调度,不应再次复制 Gmail / 2925 规则
|
||||
- `background/signup-flow-helpers.js` 只负责“复用已有邮箱还是重新生成”的流程决策
|
||||
- `sidepanel/sidepanel.js` 只负责 UI 接线、校验触发和状态同步
|
||||
- Hotmail / 2925 账号池这类跨 provider 的 sidepanel 表单显隐、头部按钮文案切换与共用操作行,应优先收敛到共享 UI helper(当前为 `sidepanel/account-pool-ui.js`),不要在各自 manager 中复制一套近似状态机
|
||||
- 如果 `2925` 的某个运行态模式会让“别名基邮箱”和“账号池 / 当前账号选择”出现不同显隐规则,必须优先拆行或拆成独立配置块,不能把账号池开关绑死在别名基邮箱那一行里
|
||||
|
||||
### 3.3 新增配置项
|
||||
|
||||
@@ -109,14 +120,27 @@
|
||||
6. 是否挂在正确的职责域中
|
||||
7. 文档
|
||||
|
||||
### 3.4 新增顶部入口按钮
|
||||
### 3.4 新增运行态模式
|
||||
|
||||
如果新增的是 sidepanel 顶部快捷入口按钮,至少同步检查:
|
||||
如果新增的是“运行态 UI 模式”而不是持久配置或新来源,必须先把边界说清楚,再落代码。
|
||||
|
||||
1. 是否应该复用现有确认弹窗与统一跳转逻辑
|
||||
2. 是否需要在步骤运行中或自动运行中禁用
|
||||
3. 是否补了最小 HTML 接线测试和按钮行为测试
|
||||
4. 是否同步更新结构文档与链路文档
|
||||
当前约定示例:
|
||||
|
||||
- `contributionMode` 是 sidepanel 的运行态 UI 模式,不是新的 `panelMode`
|
||||
- `panelMode` 当前允许 `cpa | sub2api | codex2api`
|
||||
- 运行态模式不能混进 `PERSISTED_SETTING_DEFAULTS`
|
||||
- 运行态模式不能混进配置导入/导出
|
||||
- 如果运行态模式会临时覆盖某些持久配置的显示值,必须同时处理好“退出模式后恢复”和“自动保存不能误覆盖原配置”这两个问题
|
||||
- 如果运行态模式要隐藏某一行 UI,必须先检查这行里是否绑定了不该一起隐藏的其他设置;必要时先拆行,再做显隐
|
||||
|
||||
当前贡献模式补充约定:
|
||||
|
||||
- 贡献模式属于 `CPA` 来源下的特殊业务模式,不是新的 provider
|
||||
- 贡献模式允许扩展内承接公开 OAuth 交互,但只能调用公开接口,不能触碰 `/v0/management/*`
|
||||
- 如果产品要求“开始贡献”和主自动流走同一条链路,则优先把贡献服务接入步骤 7 / 10,而不是额外分出一套平行 mini-flow
|
||||
- 贡献流程的后台公开 OAuth 状态机应优先收敛到独立模块,例如 `background/contribution-oauth.js`
|
||||
- 贡献模式的侧栏按钮、状态展示和轮询调度应优先收敛到独立 manager,例如 `sidepanel/contribution-mode.js`
|
||||
- 如果服务端当前返回“无需手动提交 callback”,扩展端必须把它当兼容成功态处理,不能简单按 HTTP 非 200 直接视为失败
|
||||
|
||||
## 4. 测试规范
|
||||
|
||||
|
||||
+36
-17
@@ -27,7 +27,8 @@
|
||||
- `icloud-utils.js`:iCloud 隐私邮箱相关的纯工具函数,负责 host、别名列表、保留状态、已用状态等归一化。
|
||||
- `luckmail-utils.js`:LuckMail 相关的纯工具函数,负责邮箱购买记录、标签、邮件 cursor、验证码匹配等归一化。
|
||||
- `mail-provider-utils.js`:网页邮箱 provider 配置纯工具,负责 `163 / 163 VIP / 126 / QQ / Inbucket / Hotmail` 的基础归一化与页面入口配置。
|
||||
- `managed-alias-utils.js`:共享 Gmail / 2925 的别名邮箱规则,负责解析基邮箱、校验“当前完整注册邮箱”是否仍与基邮箱兼容、生成 Gmail `+tag` 与 2925 随机后缀邮箱,并输出 sidepanel 可复用的 UI 文案。
|
||||
- `mail2925-utils.js`:2925 账号池相关的纯工具函数,负责账号归一化、冷却期判断、可用账号挑选、批量导入解析与列表更新。
|
||||
- `managed-alias-utils.js`:共享 Gmail / 2925 的别名邮箱规则,负责解析基邮箱、校验“当前完整注册邮箱”是否仍与基邮箱兼容、生成 Gmail `+tag` 与 2925 随机后缀邮箱,并输出 sidepanel 可复用的 UI 文案;当前还统一承接 `mail2925Mode` 的归一化与“2925 仅在 provide 模式下参与别名生成”的共享判定。
|
||||
- `manifest.json`:Chrome 扩展清单,声明权限、背景脚本、侧边栏、内容脚本与规则集。
|
||||
- `microsoft-email.js`:Microsoft Graph / Outlook 邮件读取辅助模块,负责刷新令牌换 token、邮箱夹轮询和验证码提取。
|
||||
- `package.json`:仓库最小 Node 包配置,目前主要提供测试脚本定义。
|
||||
@@ -42,27 +43,29 @@
|
||||
## `background/`
|
||||
|
||||
- `background/account-run-history.js`:邮箱记录模块,负责以邮箱为主键维护最新记录(同邮箱后续状态覆盖),在步骤 2 设定邮箱后支持先写入“停止/未完成”占位状态,统一归一化成功/失败/停止三态并落地到 `chrome.storage.local`,支持清理记录,并在启用独立本地同步配置后把完整快照同步到本地 helper。
|
||||
- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail` 与 `mail2925BaseEmail`,避免自动流程重置后丢失别名基邮箱配置。
|
||||
- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口。
|
||||
- `background/auto-run-controller.js`:自动运行主控制器,封装多轮执行、重试、轮次摘要、线程间隔与倒计时恢复逻辑;当前自动流程会绑定 `autoRunSessionId`,手动停止后旧的倒计时计划、旧重试链路和旧恢复入口不会再复活已失效的自动运行;fresh-attempt reset 时会额外保留 `gmailBaseEmail`、`mail2925BaseEmail` 与当前 2925 账号选择,避免自动流程重置后丢失别名基邮箱与 2925 切号上下文。
|
||||
- `background/contribution-oauth.js`:贡献模式的公开 OAuth 流程模块,负责调用 `apikey.qzz.io` 的公开贡献接口、保存贡献会话运行态、在主 10 步流程里为步骤 7 提供贡献登录地址、在步骤 9/10 衔接 callback 捕获与兼容提交 `/oauth/api/submit-callback`,并把真实服务端状态映射回 sidepanel 运行态。
|
||||
- `background/generated-email-helpers.js`:生成邮箱辅助层,除了 Duck / Cloudflare / iCloud / Cloudflare Temp Email,也统一承接 Gmail / 2925 的别名邮箱生成入口;当 provider 为 2925 且 `mail2925Mode = provide` 时,会先确保当前账号池里已有可用账号,再基于该账号生成别名邮箱;若 `mail2925Mode = receive`,则会回退到普通邮箱生成链路。
|
||||
- `background/logging-status.js`:后台日志、步骤状态、错误信息和若干状态判断的公共工具层;当前额外承接 `add-phone / 手机号页` 这类认证 fatal 错误的共享判定。
|
||||
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息。
|
||||
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API 地址、步骤跳转相关判断。
|
||||
- `background/panel-bridge.js`:CPA / SUB2API 面板桥接层,封装 OAuth 地址获取所需的页面打开、脚本注入和通信。
|
||||
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail / 2925 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成。
|
||||
- `background/mail-2925-session.js`:2925 会话模块,负责 2925 账号池持久化、当前账号切换、cookie 清理登出、自动登录、命中“子邮箱已达上限邮箱”后的 24 小时禁用与自动切号。
|
||||
- `background/message-router.js`:后台消息路由层,负责处理 `chrome.runtime.onMessage` 进入的所有业务消息;当前额外接入 2925 账号池的新增、导入、切换、登录、禁用与删除消息。
|
||||
- `background/navigation-utils.js`:导航与 URL 判断工具层,负责 callback、入口页、CPA / SUB2API / Codex2API 地址归一化、来源标签页家族判断与步骤跳转相关判断。
|
||||
- `background/panel-bridge.js`:来源桥接层;CPA / SUB2API 继续封装页面打开、脚本注入和通信,Codex2API 则直接通过后台协议生成 OAuth 地址。
|
||||
- `background/signup-flow-helpers.js`:注册页辅助层,负责打开注册入口、等待密码页以及解析当前流程所用邮箱;在 Gmail 与 `2925 + provide` 模式下会优先复用已经存在且仍兼容的完整注册邮箱,只有不兼容或为空时才重新生成;当 provider 为 2925 且启用了 provide 模式下的号池时,会先确保账号池中已选中可用账号。
|
||||
- `background/tab-runtime.js`:标签页与内容脚本运行时基础设施,封装标签注册、冲突清理、消息超时、注入重试与队列;当前等待标签完成、注入后的短暂延迟和内容脚本重试等待都已做 Stop 感知,避免用户停止后后台还继续等待并恢复执行。
|
||||
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 7 次,步骤 8 提交登录验证码后若页面进入 `add-phone / 手机号页`,会直接抛出 fatal 错误而不是把当前步骤视为成功,并在 2925 提交成功后异步触发“删除全部邮件”的清理消息。
|
||||
- `background/verification-flow.js`:注册/登录验证码共享流程层,封装重发、轮询、提交、失败回退、自定义邮箱跳过、共享验证码自动重发次数配置以及 2925 长轮询参数;当前验证码提交重试上限为 15 次,2925 每次重发验证码之间都会固定跑完一轮 15 次邮箱刷新轮询;若 `mail2925Mode = receive`,会额外把目标注册邮箱传给 2925 内容脚本做弱匹配;若 2925 轮询命中“子邮箱已达上限邮箱”,会转入 2925 会话模块执行“记录时间、禁用 24 小时、切下一个账号并重新登录”,然后直接结束当前尝试。
|
||||
|
||||
## `background/steps/`
|
||||
|
||||
- `background/steps/clear-login-cookies.js`:步骤 6 实现,负责登录前 Cookie 清理。
|
||||
- `background/steps/confirm-oauth.js`:步骤 9 实现,负责 OAuth 同意页按钮定位、点击、localhost 回调监听与回调完成。
|
||||
- `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱。
|
||||
- `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口。
|
||||
- `background/steps/fetch-login-code.js`:步骤 8 实现,负责登录验证码阶段的邮箱轮询、验证码回填与回退控制;验证码获取后直接提交,不再在提交前回放步骤 7 刷新 OAuth;对非 2925 provider 会固定当前验证码页显示邮箱作为本次 Step 8 的目标邮箱;当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。
|
||||
- `background/steps/fetch-signup-code.js`:步骤 4 实现,负责注册验证码阶段的页面准备与验证码流程入口;当 provider 为 2925 时,会在轮询前先确保当前 2925 账号已自动登录。
|
||||
- `background/steps/fill-password.js`:步骤 3 实现,负责密码生成、保存、回填与提交。
|
||||
- `background/steps/fill-profile.js`:步骤 5 实现,负责姓名、生日填写并把资料提交给注册页内容脚本。
|
||||
- `background/steps/oauth-login.js`:步骤 7 实现,负责刷新 OAuth 链接、登录和确保进入验证码页;普通可恢复登录态失败会按上限重试,但一旦识别到认证流程进入 `add-phone / 手机号页`,会立即退出步骤 7 内部重试。
|
||||
- `background/steps/open-chatgpt.js`:步骤 1 实现,负责打开 ChatGPT 官网并确认入口就绪。
|
||||
- `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证。
|
||||
- `background/steps/platform-verify.js`:步骤 10 实现,负责 CPA / SUB2API 回调验证,以及 Codex2API 的协议式 callback code/state 交换。
|
||||
- `background/steps/registry.js`:步骤注册表工厂,负责用稳定的步骤元数据映射到执行器。
|
||||
- `background/steps/submit-signup-email.js`:步骤 2 实现,负责注册入口点击、邮箱提交与提交后落地页分支判断。
|
||||
|
||||
@@ -75,7 +78,7 @@
|
||||
- `content/icloud-mail.js`:iCloud 邮箱页面脚本,负责在 iCloud Mail 页面中读取邮件详情和验证码。
|
||||
- `content/inbucket-mail.js`:Inbucket 邮箱轮询脚本,负责在 Inbucket 页面中读取、删除验证码邮件。
|
||||
- `content/mail-163.js`:163 / 163 VIP / 126 邮箱轮询脚本,负责网页邮箱验证码读取和邮件清理。
|
||||
- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱收件轮询、按步骤会话隔离“已试验证码”、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理。
|
||||
- `content/mail-2925.js`:2925 邮箱页面脚本,负责 2925 邮箱自动登录态确认、收件轮询、按步骤会话隔离“已试验证码”、在每次重发验证码之间执行一轮最多 15 次的邮箱刷新轮询、命中邮件后立即删当前邮件,以及在成功后配合后台执行整箱清理;若页面出现“子邮箱已达上限邮箱”提示,会立即上报后台进入切号链路;当后台显式开启 `mail2925MatchTargetEmail` 时,会对邮件里显式出现的目标邮箱做弱匹配,避免 `receive` 模式误捞别人的验证码。
|
||||
- `content/qq-mail.js`:QQ 邮箱轮询脚本,负责网页邮箱验证码读取。
|
||||
- `content/signup-page.js`:注册、登录、授权主内容脚本,负责 OpenAI / ChatGPT 页面上的步骤执行;其中 Step 2 / 3 的延迟点击与延迟提交在真正触发前会先检查 Stop 状态,避免停止后页面继续自动点击。
|
||||
- `content/sub2api-panel.js`:SUB2API 后台内容脚本,负责获取 OAuth 地址和提交 localhost 回调;当前承接步骤 10。
|
||||
@@ -90,6 +93,7 @@
|
||||
## `docs/`
|
||||
|
||||
- `docs/仓库协作者AI分析PR与合并标准流程.md`:仓库协作者进行 AI 分析 PR 与合并时的流程说明。
|
||||
- `docs/使用教程.md`:面向使用者的补充教程文档,当前说明扩展更新流程与 Clash Verge 的“非港轮询”配置步骤。
|
||||
- `docs/images/交流群.jpg`:README 中展示的交流群图片资源。
|
||||
- `docs/images/十轮自动.png`:README 中展示的自动运行效果图。
|
||||
- `docs/images/微信.png`:README 中展示的微信收款码图片。
|
||||
@@ -110,13 +114,17 @@
|
||||
|
||||
## `sidepanel/`
|
||||
|
||||
- `sidepanel/account-pool-ui.js`:侧边栏号池表单共享 UI helper,负责 Hotmail / 2925 账号池新增表单的显隐、头部“添加账号/取消添加”按钮文案切换、清空表单与首字段聚焦。
|
||||
- `sidepanel/hotmail-manager.js`:侧边栏 Hotmail 账号池管理器,负责列表、导入、验证、测试收信和批量操作。
|
||||
- `sidepanel/icloud-manager.js`:侧边栏 iCloud 隐私邮箱管理器,负责列表、筛选、保留、删除和批量操作。
|
||||
- `sidepanel/luckmail-manager.js`:侧边栏 LuckMail 管理器,负责邮箱列表、筛选、启停、保留与批量操作。
|
||||
- `sidepanel/mail-2925-manager.js`:侧边栏 2925 账号池管理器,负责 2925 账号的新增、导入、切换、手动登录、启停、清冷却与删除。
|
||||
- `sidepanel/account-records-manager.js`:侧边栏邮箱记录面板管理器,负责“记录”按钮、覆盖层开关、分页列表、成功/失败/停止统计摘要和清理确认。
|
||||
- `sidepanel/sidepanel.css`:侧边栏样式文件。
|
||||
- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增临时 `贡献` 按钮用于直接打开账号贡献上传页,同时继续加载 `managed-alias-utils.js`,把旧的“邮箱前缀”字段语义改为“别名基邮箱”。
|
||||
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / iCloud / LuckMail / 邮箱记录面板 manager;当前顶部 `贡献` 按钮会在新标签页中直接打开账号贡献上传页。
|
||||
- `sidepanel/contribution-content-update-service.js`:侧边栏贡献内容更新服务,负责拉取 `https://apikey.qzz.io/api/content-summary`、缓存公开公告/教程摘要,并输出可用于提示展示的 `promptVersion` 与最新更新时间。
|
||||
- `sidepanel/contribution-mode.js`:侧边栏贡献模式管理器,负责顶部“贡献”按钮、确认弹窗、贡献模式显隐、复用主自动流程启动、侧栏内贡献状态轮询、上传页跳转,以及贡献模式下对来源选择、配置入口、记录入口和敏感配置行(包括 Codex2API 配置)的禁用与隐藏。
|
||||
- `sidepanel/sidepanel.css`:侧边栏样式文件;当前额外提供 Hotmail / 2925 共用的号池表单容器、操作按钮行与统一的收起态列表高度样式。
|
||||
- `sidepanel/sidepanel.html`:侧边栏页面结构;当前步骤列表已改为动态容器,日志区提供“记录”按钮并挂接邮箱记录覆盖层,顶部新增“贡献”按钮并在设置卡片中新增贡献模式主面板;贡献按钮下方额外挂接一个可关闭的轻提示气泡,用于提示公开公告 / 使用教程有更新;贡献面板内展示 `OAUTH / 回调 / 总状态` 三块真实运行态信息,同时把“本地同步”与“验证码重发”拆成独立行以避免特殊模式隐藏时互相影响;页面继续加载 `managed-alias-utils.js`,并把旧的“邮箱前缀”字段语义改为“别名基邮箱”;当 provider 为 2925 时,会额外显示 `提供邮箱 / 接收邮箱` 模式切换,并把“2925 号池”从别名基邮箱行拆成独立配置行,避免 receive 模式把账号池开关一起隐藏;来源下拉框当前支持 `CPA / SUB2API / Codex2API`,其中 Codex2API 额外提供后台地址和管理密钥配置行;Hotmail / 2925 两个账号池当前都使用统一的头部“添加账号/取消添加”按钮和共享表单容器。
|
||||
- `sidepanel/sidepanel.js`:侧边栏主入口脚本,负责 UI 状态同步、动态步骤渲染、按钮交互、独立本地同步配置、共享验证码自动重发次数配置与广播接收,并装配 Hotmail / 2925 / iCloud / LuckMail / 贡献模式 / 邮箱记录面板 / 贡献内容更新服务;当前贡献模式的“开始贡献”会直接复用主自动流程启动逻辑,而独立 manager 负责贡献运行态展示与轮询,同时把 Gmail / 2925 的基邮箱输入、完整注册邮箱输入、自动生成按钮与兼容性校验统一接到共享别名逻辑上;当 provider 为 2925 时,会根据 `mail2925Mode` 决定是否启用别名基邮箱链路,而 2925 号池开关与当前账号选择则独立显示并同时服务于 provide / receive 两种模式;新来源 Codex2API 在这里仅补充来源配置接线、表单显隐和 Step 10 按钮文案,不承接协议业务逻辑;Hotmail / 2925 的新增表单显隐统一接到 `sidepanel/account-pool-ui.js` 这一层共享 helper;侧边栏初始化与点击“自动”前会刷新一次贡献站公开内容摘要,并按本地关闭版本决定是否展示轻提示。
|
||||
- `sidepanel/update-service.js`:侧边栏更新检查服务,负责 GitHub Releases 查询、`Pro` / `v` 双版本族排序、缓存读取与版本展示。
|
||||
|
||||
## `tests/`
|
||||
@@ -124,6 +132,7 @@
|
||||
- `tests/activation-utils.test.js`:测试内容脚本激活策略与平台回调可恢复错误判断。
|
||||
- `tests/auth-page-recovery.test.js`:测试认证页共享恢复层的重试页识别与恢复行为。
|
||||
- `tests/auto-run-fresh-attempt-reset.test.js`:测试自动运行在新一轮开始前会重置旧运行时上下文,并补充 `gmailBaseEmail` / `mail2925BaseEmail` 在 fresh reset 后仍被保留的回归验证。
|
||||
- `tests/auto-run-step4-mail2925-thread-terminate.test.js`:测试步骤 4 命中 2925“结束当前尝试”错误时,不会再沿用当前邮箱回到步骤 1 重开,而是直接把错误抛给自动重试控制器。
|
||||
- `tests/auto-run-timer-session-guard.test.js`:测试自动运行的旧计时计划在 Stop 使 session 失效后,不能再把已停止的自动流程重新拉起。
|
||||
- `tests/auto-run-add-phone-stop.test.js`:测试自动运行控制器在开启自动重试时,遇到 `add-phone / 手机号页` 会直接结束当前轮并继续下一轮,而不是把整条自动流程暂停。
|
||||
- `tests/auto-run-step6-restart.test.js`:测试自动运行在后半段授权链路遇错时会回到步骤 7 重开,并在命中 add-phone 或泛化手机号页 fatal 错误时停止重开。
|
||||
@@ -131,15 +140,19 @@
|
||||
- `tests/background-auto-run-module.test.js`:测试自动运行控制器模块已接入且导出工厂。
|
||||
- `tests/background-account-run-history-module.test.js`:测试邮箱记录模块已接入、导出工厂,能够保留并归一化停止记录、归一化失败标签、计算自动重试次数,并支持清理后同步完整快照。
|
||||
- `tests/background-account-history-settings.test.js`:测试账号运行历史的独立配置项归一化,不再复用 Hotmail 模式作为启停条件。
|
||||
- `tests/background-contribution-mode.test.js`:测试贡献模式的后台运行态与公开 OAuth 接入,覆盖 `contributionMode` 只存在于运行态、`SET_CONTRIBUTION_MODE / POLL_CONTRIBUTION_STATUS` 消息接入、reset 保留贡献运行态,以及 callback 自动提交在“无需手动提交”场景下的兼容处理。
|
||||
- `tests/background-generated-email-module.test.js`:测试生成邮箱辅助模块已接入且导出工厂。
|
||||
- `tests/background-icloud.test.js`:测试 iCloud 相关后台纯函数与别名收尾逻辑。
|
||||
- `tests/background-icloud-mail-provider.test.js`:测试 iCloud 邮箱 provider 配置解析。
|
||||
- `tests/background-logging-status-module.test.js`:测试日志 / 状态模块已接入且导出工厂。
|
||||
- `tests/background-luckmail.test.js`:测试 LuckMail 相关后台逻辑,如购买、复用、标记已用与重置。
|
||||
- `tests/background-mail2925-session-module.test.js`:测试 2925 会话模块已接入且导出工厂,并覆盖命中上限后“禁用 24 小时 + 切下一个号 + 自动登录”的核心链路。
|
||||
- `tests/background-mail2925-signup-flow.test.js`:测试注册页辅助层在 2925 provider 下,会先确保账号池里已分配可用账号,再继续生成别名邮箱。
|
||||
- `tests/background-message-router-module.test.js`:测试消息路由模块已接入且导出工厂。
|
||||
- `tests/background-message-router-step2-skip.test.js`:测试步骤 2 直接落到验证码页时的跳步与状态保护逻辑。
|
||||
- `tests/background-navigation-utils-module.test.js`:测试导航工具模块已接入且导出工厂。
|
||||
- `tests/background-panel-bridge-module.test.js`:测试面板桥接模块已接入且导出工厂。
|
||||
- `tests/background-platform-verify-codex2api.test.js`:测试 Codex2API 新来源在步骤 10 走协议式 callback 交换,不依赖后台页面注入。
|
||||
- `tests/background-signup-flow-module.test.js`:测试注册页辅助模块已接入且导出工厂。
|
||||
- `tests/background-skip-step-linking.test.js`:测试手动跳过步骤 1 时,会级联跳过步骤 2~5,并仅跳过其中未完成且未运行的步骤。
|
||||
- `tests/background-signup-step2-branching.test.js`:测试在 Gmail / 2925 模式下,已有兼容别名邮箱时应直接复用,不应再次重生成。
|
||||
@@ -161,14 +174,20 @@
|
||||
- `tests/luckmail-utils.test.js`:测试 LuckMail 工具层的购买记录、邮件、游标和验证码匹配逻辑。
|
||||
- `tests/mail-provider-utils.test.js`:测试网页邮箱 provider 配置工具对 `126` 等 provider 的归一化与入口配置解析。
|
||||
- `tests/mail-2925-content.test.js`:测试 2925 邮箱内容脚本的轮询快照、忽略邮箱对比、按步骤会话隔离已试验证码、单封邮件删除与整箱删除逻辑。
|
||||
- `tests/mail2925-utils.test.js`:测试 2925 账号池工具层的账号归一化、冷却期判断、可用账号挑选与批量导入解析。
|
||||
- `tests/managed-alias-utils.test.js`:覆盖 Gmail / 2925 共享别名工具的解析、生成、兼容性判断。
|
||||
- `tests/microsoft-email.test.js`:测试 Microsoft 邮件拉取与验证码提取逻辑。
|
||||
- `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线与空态渲染。
|
||||
- `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线,以及直接打开账号贡献上传页的最小行为。
|
||||
- `tests/sidepanel-hotmail-manager.test.js`:测试侧边栏 Hotmail 管理器模块接线、共享号池表单显隐交互与空态渲染。
|
||||
- `tests/sidepanel-contribution-button.test.js`:测试侧边栏顶部 `贡献` 按钮的 HTML 接线、更新提示气泡接线,以及相关脚本加载顺序。
|
||||
- `tests/sidepanel-account-records-manager.test.js`:测试侧边栏邮箱记录覆盖层的 HTML 接入、helper 地址归一化与 manager 渲染逻辑。
|
||||
- `tests/contribution-content-update-service.test.js`:测试贡献内容更新服务对公开摘要接口的归一化、版本提取与失败回退缓存逻辑。
|
||||
- `tests/sidepanel-contribution-mode.test.js`:测试侧边栏贡献模式的 HTML 接线、runtime-only 设置保护,以及贡献模式 manager 复用主自动流启动、状态轮询和退出清理逻辑。
|
||||
- `tests/sidepanel-auto-run-content-refresh.test.js`:测试点击“自动”时会先刷新贡献内容更新摘要,且刷新失败不会阻塞自动流程启动。
|
||||
- `tests/sidepanel-icloud-manager.test.js`:测试侧边栏 iCloud 管理器模块接线与空态渲染。
|
||||
- `tests/sidepanel-icloud-provider.test.js`:测试侧边栏 iCloud 登录地址解析逻辑。
|
||||
- `tests/sidepanel-luckmail-manager.test.js`:测试侧边栏 LuckMail 管理器模块接线与空态渲染。
|
||||
- `tests/sidepanel-mail2925-manager.test.js`:测试侧边栏 2925 管理器模块接线、共享号池表单脚本加载顺序、显隐交互与空态渲染。
|
||||
- `tests/sidepanel-mail2925-mode.test.js`:测试侧边栏保留 `2925 provide / receive` 模式行与独立的号池配置行,并验证 sidepanel 只在 provide 模式下把 2925 视为别名邮箱 provider。
|
||||
- `tests/signup-entry-diagnostics.test.js`:测试注册入口诊断快照输出。
|
||||
- `tests/signup-page-tab-cleanup.test.js`:测试注册页来源标签的冲突清理逻辑。
|
||||
- `tests/step-definitions-module.test.js`:测试共享步骤定义模块及侧边栏脚本加载顺序。
|
||||
|
||||
Reference in New Issue
Block a user