mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
chore: standardize repository formatting and editor setup
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
end_of_line = lf
|
||||||
|
indent_style = space
|
||||||
|
indent_size = 4
|
||||||
|
insert_final_newline = true
|
||||||
|
|
||||||
|
[*.md]
|
||||||
|
trim_trailing_whitespace = false
|
||||||
+11
-2
@@ -1,4 +1,13 @@
|
|||||||
{
|
{
|
||||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
"ignorePatterns": [".agents/**", ".codex/**", ".trellis/**", "AGENTS.md", "pnpm-lock.yaml"]
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"ignorePatterns": [
|
||||||
|
".agents/**",
|
||||||
|
".codex/**",
|
||||||
|
".trellis/**",
|
||||||
|
"AGENTS.md",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"prd.html"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
- 仓库统一使用 Oxfmt;lint-staged 只格式化暂存文件,Husky 在 `pre-commit` 阶段触发该检查。
|
- 仓库统一使用 Oxfmt;lint-staged 只格式化暂存文件,Husky 在 `pre-commit` 阶段触发该检查。
|
||||||
- 扩展包使用 Vite `6.x` 和 TypeScript `5.7.x`。`typecheck` 执行 `tsc -p tsconfig.json --noEmit`,`build` 先检查类型再执行 `vite build`,`test` 使用 Node 内置 test runner;尚无 lint 脚本。
|
- 扩展包使用 Vite `6.x` 和 TypeScript `5.7.x`。`typecheck` 执行 `tsc -p tsconfig.json --noEmit`,`build` 先检查类型再执行 `vite build`,`test` 使用 Node 内置 test runner;尚无 lint 脚本。
|
||||||
- Vite 依赖的 `esbuild` 安装脚本在根 `pnpm-workspace.yaml` 的 `allowBuilds` 中显式允许。
|
- Vite 依赖的 `esbuild` 安装脚本在根 `pnpm-workspace.yaml` 的 `allowBuilds` 中显式允许。
|
||||||
|
- 代码格式统一使用根目录 Oxfmt,缩进为 4 个空格;VSCode 保存格式化使用工作区推荐的 `oxc.oxc-vscode`,不得使用其它 formatter。
|
||||||
|
|
||||||
## 必须遵守
|
## 必须遵守
|
||||||
|
|
||||||
@@ -32,6 +33,8 @@ pnpm build
|
|||||||
pnpm test
|
pnpm test
|
||||||
```
|
```
|
||||||
|
|
||||||
|
编辑器保存格式化必须与 `pnpm format` 相同。VSCode 使用 `.vscode/settings.json` 指向根 `.oxfmtrc.json`;未安装 Oxc 扩展时关闭 `formatOnSave`,不要让内置 TypeScript formatter 或其它 formatter 生成提交前会被改写的代码。
|
||||||
|
|
||||||
其中 `typecheck` 会执行严格 TypeScript 检查,`build` 会生成 `apps/chrome-extension/dist/`,`test` 会执行 `apps/chrome-extension/test/*.test.js`。新增非平凡逻辑时,应补充最小可运行测试。
|
其中 `typecheck` 会执行严格 TypeScript 检查,`build` 会生成 `apps/chrome-extension/dist/`,`test` 会执行 `apps/chrome-extension/test/*.test.js`。新增非平凡逻辑时,应补充最小可运行测试。
|
||||||
|
|
||||||
## 评审清单
|
## 评审清单
|
||||||
@@ -43,3 +46,4 @@ pnpm test
|
|||||||
- Popup 移动后,Vite input、TypeScript include、Manifest 路径和 `dist/` 产物是否一致?
|
- Popup 移动后,Vite input、TypeScript include、Manifest 路径和 `dist/` 产物是否一致?
|
||||||
- 是否覆盖了用户可见的加载、空、错误和键盘交互状态?
|
- 是否覆盖了用户可见的加载、空、错误和键盘交互状态?
|
||||||
- 是否执行了当前可用的根级验证命令,并如实说明跳过项?
|
- 是否执行了当前可用的根级验证命令,并如实说明跳过项?
|
||||||
|
- 编辑器保存后的代码是否仍通过 Oxfmt 检查,并保持 4 个空格缩进?
|
||||||
|
|||||||
@@ -174,6 +174,16 @@ types / constants
|
|||||||
|
|
||||||
这里的“主函数”是文件中负责组织其它声明完成该文件主要职责的函数,通常是入口函数、编排函数或主要公开函数;不是按函数长度判断,也不是强制命名为 `main`。
|
这里的“主函数”是文件中负责组织其它声明完成该文件主要职责的函数,通常是入口函数、编排函数或主要公开函数;不是按函数长度判断,也不是强制命名为 `main`。
|
||||||
|
|
||||||
|
### 3.8 格式化器唯一来源与编辑器保存
|
||||||
|
|
||||||
|
仓库统一使用根目录 [`package.json`](../../package.json) 声明的 Oxfmt。缩进使用 4 个空格,不使用 Tab;具体格式规则由根目录 [`.oxfmtrc.json`](../../.oxfmtrc.json) 维护,基础编辑器空白行为由 [`.editorconfig`](../../.editorconfig) 对齐。
|
||||||
|
|
||||||
|
1. `pnpm format` 是修改格式的唯一标准命令,`pnpm format:check` 是提交前的格式门禁。
|
||||||
|
2. VSCode 保存格式化只能使用 `oxc.oxc-vscode`,并且必须读取仓库的 `.oxfmtrc.json`;工作区设置位于 `.vscode/settings.json`,推荐扩展位于 `.vscode/extensions.json`。
|
||||||
|
3. 未安装 Oxc 扩展时,不得让 VSCode 内置 TypeScript formatter、Prettier、Biome 或其它 formatter 接管保存格式化;应先安装 Oxc 扩展,或关闭 `formatOnSave` 后执行 `pnpm format`。
|
||||||
|
4. `.editorconfig` 只提供缩进、换行和文件末尾换行等基础编辑器行为,不能替代 Oxfmt,也不能成为第二套格式规则。
|
||||||
|
5. 提交钩子、编辑器保存和 CI 检查必须产生同一份 Oxfmt 结果;若保存后再次运行 `pnpm format` 仍产生差异,视为格式化配置冲突,必须先修复配置。
|
||||||
|
|
||||||
## 4. Validation & Error Matrix
|
## 4. Validation & Error Matrix
|
||||||
|
|
||||||
| 发现的代码形态 | 处理 |
|
| 发现的代码形态 | 处理 |
|
||||||
@@ -190,6 +200,8 @@ types / constants
|
|||||||
| 文件头缺少职责注释,或正文少于 10 / 多于 30 个字符 | 补充或改写为 10–30 个字符的职责描述 |
|
| 文件头缺少职责注释,或正文少于 10 / 多于 30 个字符 | 补充或改写为 10–30 个字符的职责描述 |
|
||||||
| 入口文件头只描述“这是入口” | 改为描述入口所在目录的整体职责 |
|
| 入口文件头只描述“这是入口” | 改为描述入口所在目录的整体职责 |
|
||||||
| 有主函数但主函数前没有独立职责注释 | 在主函数声明正上方补充职责说明 |
|
| 有主函数但主函数前没有独立职责注释 | 在主函数声明正上方补充职责说明 |
|
||||||
|
| 编辑器保存后与 Oxfmt 结果不同 | 将保存 formatter 切换为 Oxc,或关闭保存格式化后运行 `pnpm format` |
|
||||||
|
| 代码使用 2 空格或 Tab,与项目约定不一致 | 按 `.oxfmtrc.json` 和 `.editorconfig` 统一为 4 个空格 |
|
||||||
|
|
||||||
## 5. Good / Base / Bad Cases
|
## 5. Good / Base / Bad Cases
|
||||||
|
|
||||||
@@ -208,6 +220,8 @@ types / constants
|
|||||||
- 入口、分发和分支重构应保留原有行为测试,证明只改变职责归属,没有改变输出契约。
|
- 入口、分发和分支重构应保留原有行为测试,证明只改变职责归属,没有改变输出契约。
|
||||||
- 新增或修改手写代码时,检查文件第一行职责注释的正文长度为 10–30 个字符;入口文件还要检查注释描述的是目录职责。
|
- 新增或修改手写代码时,检查文件第一行职责注释的正文长度为 10–30 个字符;入口文件还要检查注释描述的是目录职责。
|
||||||
- 检查主函数前存在独立职责注释,且主函数是最后一个函数声明;允许其后出现直接启动调用或显式导出。
|
- 检查主函数前存在独立职责注释,且主函数是最后一个函数声明;允许其后出现直接启动调用或显式导出。
|
||||||
|
- 检查手工保存后的文件通过 `pnpm format:check`,且 VSCode 使用 `oxc.oxc-vscode` 读取根 `.oxfmtrc.json`。
|
||||||
|
- 检查代码缩进为 4 个空格,未混入 Tab、Prettier 或其它 formatter 的结果。
|
||||||
|
|
||||||
## 7. Wrong vs Correct
|
## 7. Wrong vs Correct
|
||||||
|
|
||||||
@@ -261,6 +275,27 @@ export function parseFeatureInput(data: unknown): DomainModel[] {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// 正确:VSCode 保存与提交钩子使用同一 Oxfmt 配置。
|
||||||
|
{
|
||||||
|
"oxc.fmt.configPath": "${workspaceFolder}/.oxfmtrc.json",
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```jsonc
|
||||||
|
// 错误:保存时使用未声明的其它 formatter。
|
||||||
|
{
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "some.other-formatter",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// 组装并启动当前目录的历史同步能力
|
// 组装并启动当前目录的历史同步能力
|
||||||
import { fetchPage } from "./sdk.ts";
|
import { fetchPage } from "./sdk.ts";
|
||||||
@@ -286,6 +321,7 @@ export function syncHistory(): void {
|
|||||||
- `utils.ts` 是否仍然不含业务流程、重要类型和业务常量?
|
- `utils.ts` 是否仍然不含业务流程、重要类型和业务常量?
|
||||||
- 基础原语是否保持无业务语义,而不是通过业务化命名制造无行为差异的包装?
|
- 基础原语是否保持无业务语义,而不是通过业务化命名制造无行为差异的包装?
|
||||||
- 重要常量是否位于使用它的所有者或功能级 `constants.ts`?
|
- 重要常量是否位于使用它的所有者或功能级 `constants.ts`?
|
||||||
|
- 编辑器保存是否与 Oxfmt 结果一致,并且缩进是否统一为 4 个空格?
|
||||||
- 文件第一行是否有 10–30 个字符的职责注释,入口文件是否描述目录职责?
|
- 文件第一行是否有 10–30 个字符的职责注释,入口文件是否描述目录职责?
|
||||||
- 主函数是否有独立职责注释并位于所有依赖声明之后?
|
- 主函数是否有独立职责注释并位于所有依赖声明之后?
|
||||||
- 入口函数是否是最后一个函数声明,其后是否只有启动调用或显式导出?
|
- 入口函数是否是最后一个函数声明,其后是否只有启动调用或显式导出?
|
||||||
|
|||||||
Vendored
+3
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"recommendations": ["oxc.oxc-vscode"]
|
||||||
|
}
|
||||||
Vendored
+29
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"oxc.fmt.configPath": "${workspaceFolder}/.oxfmtrc.json",
|
||||||
|
"oxc.path.oxfmt": "${workspaceFolder}/node_modules/.bin/oxfmt",
|
||||||
|
"editor.formatOnSaveMode": "file",
|
||||||
|
"[javascript]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[javascriptreact]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[typescript]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[typescriptreact]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[json]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
},
|
||||||
|
"[jsonc]": {
|
||||||
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
|
"editor.formatOnSave": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Trade Message Center
|
||||||
|
|
||||||
|
## VSCode 开发环境
|
||||||
|
|
||||||
|
本项目统一使用 Oxfmt 格式化代码,缩进为 4 个空格。请在 VSCode 中安装 Oxc 插件 `oxc.oxc-vscode`,否则保存文件时可能使用其它 formatter,导致代码在提交钩子中再次变化。
|
||||||
|
|
||||||
|
安装方式:
|
||||||
|
|
||||||
|
1. 打开 VSCode 扩展面板(macOS 快捷键:`⇧⌘X`)。
|
||||||
|
2. 搜索 `Oxc`,安装扩展 `oxc.oxc-vscode`。
|
||||||
|
3. 重新打开项目窗口,确认右下角或状态栏使用 Oxc formatter。
|
||||||
|
|
||||||
|
仓库已经在 `.vscode/settings.json` 中配置 Oxc 保存格式化,并通过 `.oxfmtrc.json` 固定 4 空格规则。项目本地依赖已经包含 Oxfmt,不需要单独全局安装。
|
||||||
|
|
||||||
|
如果暂时不安装插件,请关闭 VSCode 的 `formatOnSave`,需要格式化时在项目根目录执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm format
|
||||||
|
pnpm format:check
|
||||||
|
```
|
||||||
|
|
||||||
|
不要同时启用 Prettier、Biome 或 VSCode 内置 TypeScript formatter。
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
{
|
{
|
||||||
"name": "@trade-message-center/chrome-extension",
|
"name": "@trade-message-center/chrome-extension",
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite build --watch --mode development",
|
"dev": "vite build --watch --mode development",
|
||||||
"build": "tsc -p tsconfig.json && vite build",
|
"build": "tsc -p tsconfig.json && vite build",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test": "node --experimental-strip-types --test test/*.test.js"
|
"test": "node --experimental-strip-types --test test/*.test.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.2",
|
"@types/node": "^22.10.2",
|
||||||
"typescript": "^5.7.3",
|
"typescript": "^5.7.3",
|
||||||
"vite": "^6.0.7"
|
"vite": "^6.0.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,37 @@
|
|||||||
<!-- 展示当前活动标签页的扩展弹窗 -->
|
<!-- 展示当前活动标签页的扩展弹窗 -->
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<title>Trade Message Center</title>
|
<title>Trade Message Center</title>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
font:
|
font:
|
||||||
14px/1.5 system-ui,
|
14px/1.5 system-ui,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
}
|
}
|
||||||
h1 {
|
h1 {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
p {
|
p {
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
#url {
|
#url {
|
||||||
color: #6b7280;
|
color: #6b7280;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<h1>Trade Message Center</h1>
|
<h1>Trade Message Center</h1>
|
||||||
<p id="title">正在读取当前页面…</p>
|
<p id="title">正在读取当前页面…</p>
|
||||||
<p id="url"></p>
|
<p id="url"></p>
|
||||||
<script type="module" src="./popup.ts"></script>
|
<script type="module" src="./popup.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// 读取并展示当前浏览器标签页信息
|
// 读取并展示当前浏览器标签页信息
|
||||||
|
|
||||||
interface ChromeTab {
|
interface ChromeTab {
|
||||||
title?: string;
|
title?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChromeApi {
|
interface ChromeApi {
|
||||||
tabs: {
|
tabs: {
|
||||||
query(queryInfo: { active: true; currentWindow: true }): Promise<ChromeTab[]>;
|
query(queryInfo: { active: true; currentWindow: true }): Promise<ChromeTab[]>;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const chromeApi = (globalThis as unknown as { chrome: ChromeApi }).chrome;
|
const chromeApi = (globalThis as unknown as { chrome: ChromeApi }).chrome;
|
||||||
@@ -21,14 +21,14 @@ const url = urlElement;
|
|||||||
|
|
||||||
/** 查询当前活动标签页并更新弹窗文本。 */
|
/** 查询当前活动标签页并更新弹窗文本。 */
|
||||||
async function renderCurrentTab(): Promise<void> {
|
async function renderCurrentTab(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const [tab] = await chromeApi.tabs.query({ active: true, currentWindow: true });
|
const [tab] = await chromeApi.tabs.query({ active: true, currentWindow: true });
|
||||||
title.textContent = tab?.title || "无法读取当前页面";
|
title.textContent = tab?.title || "无法读取当前页面";
|
||||||
url.textContent = tab?.url || "";
|
url.textContent = tab?.url || "";
|
||||||
} catch {
|
} catch {
|
||||||
title.textContent = "无法读取当前页面";
|
title.textContent = "无法读取当前页面";
|
||||||
url.textContent = "";
|
url.textContent = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void renderCurrentTab();
|
void renderCurrentTab();
|
||||||
|
|||||||
@@ -1,29 +1,29 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Trade Message Center",
|
"name": "Trade Message Center",
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"description": "Sync authorized Alibaba, Made in China, and Global Sources conversations to TradeBridge.",
|
"description": "Sync authorized Alibaba, Made in China, and Global Sources conversations to TradeBridge.",
|
||||||
"icons": {
|
"icons": {
|
||||||
"16": "icons/icon-16.png",
|
"16": "icons/icon-16.png",
|
||||||
"32": "icons/icon-32.png",
|
"32": "icons/icon-32.png",
|
||||||
"48": "icons/icon-48.png",
|
"48": "icons/icon-48.png",
|
||||||
"128": "icons/icon-128.png"
|
"128": "icons/icon-128.png"
|
||||||
},
|
|
||||||
"permissions": ["activeTab"],
|
|
||||||
"content_scripts": [
|
|
||||||
{
|
|
||||||
"matches": ["https://onetalk.alibaba.com/*"],
|
|
||||||
"js": ["onetalk/main-page/page-script.js"],
|
|
||||||
"run_at": "document_start",
|
|
||||||
"world": "MAIN"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"action": {
|
|
||||||
"default_icon": {
|
|
||||||
"16": "icons/icon-16.png",
|
|
||||||
"32": "icons/icon-32.png",
|
|
||||||
"48": "icons/icon-48.png"
|
|
||||||
},
|
},
|
||||||
"default_popup": "popup/popup.html"
|
"permissions": ["activeTab"],
|
||||||
}
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": ["https://onetalk.alibaba.com/*"],
|
||||||
|
"js": ["onetalk/main-page/page-script.js"],
|
||||||
|
"run_at": "document_start",
|
||||||
|
"world": "MAIN"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"action": {
|
||||||
|
"default_icon": {
|
||||||
|
"16": "icons/icon-16.png",
|
||||||
|
"32": "icons/icon-32.png",
|
||||||
|
"48": "icons/icon-48.png"
|
||||||
|
},
|
||||||
|
"default_popup": "popup/popup.html"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,5 +2,5 @@
|
|||||||
|
|
||||||
/** 以错误消息终止当前控制流。 */
|
/** 以错误消息终止当前控制流。 */
|
||||||
export function fail(message: string): never {
|
export function fail(message: string): never {
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,31 @@
|
|||||||
// 代理浏览器 WebSocket 并安全旁路消息
|
// 代理浏览器 WebSocket 并安全旁路消息
|
||||||
|
|
||||||
export interface WebSocketHost {
|
export interface WebSocketHost {
|
||||||
WebSocket: typeof WebSocket;
|
WebSocket: typeof WebSocket;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 为匹配的 WebSocket 消息注册旁路观察回调。 */
|
/** 为匹配的 WebSocket 消息注册旁路观察回调。 */
|
||||||
export function observeNewWebSocketMessages(
|
export function observeNewWebSocketMessages(
|
||||||
host: WebSocketHost,
|
host: WebSocketHost,
|
||||||
matches: (url: string) => boolean,
|
matches: (url: string) => boolean,
|
||||||
observe: (data: unknown) => void,
|
observe: (data: unknown) => void,
|
||||||
): void {
|
): void {
|
||||||
const NativeWebSocket = host.WebSocket;
|
const NativeWebSocket = host.WebSocket;
|
||||||
|
|
||||||
host.WebSocket = new Proxy(NativeWebSocket, {
|
host.WebSocket = new Proxy(NativeWebSocket, {
|
||||||
construct(target, args, newTarget) {
|
construct(target, args, newTarget) {
|
||||||
const socket = Reflect.construct(target, args, newTarget) as WebSocket;
|
const socket = Reflect.construct(target, args, newTarget) as WebSocket;
|
||||||
const url = String(args[0]);
|
const url = String(args[0]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (matches(url)) {
|
if (matches(url)) {
|
||||||
socket.addEventListener("message", (event) => observe(event.data));
|
socket.addEventListener("message", (event) => observe(event.data));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Observation must never affect the page's WebSocket behavior.
|
// Observation must never affect the page's WebSocket behavior.
|
||||||
}
|
}
|
||||||
|
|
||||||
return socket;
|
return socket;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import { syncCurrentConversationHistory } from "./index.ts";
|
|||||||
|
|
||||||
/** 将当前会话历史同步能力注册到页面命名空间。 */
|
/** 将当前会话历史同步能力注册到页面命名空间。 */
|
||||||
export function installCurrentConversationHistorySync(pageWindow: OneTalkPageWindow): void {
|
export function installCurrentConversationHistorySync(pageWindow: OneTalkPageWindow): void {
|
||||||
const existing = pageWindow.__tradeMessageCenterOneTalk;
|
const existing = pageWindow.__tradeMessageCenterOneTalk;
|
||||||
const namespace: Record<string, unknown> = isRecord(existing) ? existing : {};
|
const namespace: Record<string, unknown> = isRecord(existing) ? existing : {};
|
||||||
namespace.syncCurrentConversationHistory = () => syncCurrentConversationHistory(pageWindow);
|
namespace.syncCurrentConversationHistory = () => syncCurrentConversationHistory(pageWindow);
|
||||||
pageWindow.__tradeMessageCenterOneTalk = namespace;
|
pageWindow.__tradeMessageCenterOneTalk = namespace;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,102 +3,102 @@
|
|||||||
import { fail } from "../../../lib/error.ts";
|
import { fail } from "../../../lib/error.ts";
|
||||||
import type { OneTalkPageWindow } from "../model.ts";
|
import type { OneTalkPageWindow } from "../model.ts";
|
||||||
import {
|
import {
|
||||||
activeAccountId,
|
activeAccountId,
|
||||||
assertActiveAccount,
|
assertActiveAccount,
|
||||||
fetchMessagesWithoutUpdateToRead,
|
fetchMessagesWithoutUpdateToRead,
|
||||||
findCurrentConversation,
|
findCurrentConversation,
|
||||||
messageService,
|
messageService,
|
||||||
pageSdk,
|
pageSdk,
|
||||||
} from "./sdk.ts";
|
} from "./sdk.ts";
|
||||||
import {
|
import {
|
||||||
historyErrorCodes,
|
historyErrorCodes,
|
||||||
type CurrentConversationHistorySyncResult,
|
type CurrentConversationHistorySyncResult,
|
||||||
type SyncDependencies,
|
type SyncDependencies,
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
|
|
||||||
const MIN_PAGE_DELAY_MS = 1_000;
|
const MIN_PAGE_DELAY_MS = 1_000;
|
||||||
const MAX_PAGE_DELAY_MS = 3_000;
|
const MAX_PAGE_DELAY_MS = 3_000;
|
||||||
const inFlightSyncs = new WeakMap<
|
const inFlightSyncs = new WeakMap<
|
||||||
OneTalkPageWindow,
|
OneTalkPageWindow,
|
||||||
Promise<CurrentConversationHistorySyncResult>
|
Promise<CurrentConversationHistorySyncResult>
|
||||||
>();
|
>();
|
||||||
|
|
||||||
function pageDelay(random: () => number): number {
|
function pageDelay(random: () => number): number {
|
||||||
const value = random();
|
const value = random();
|
||||||
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
if (!Number.isFinite(value) || value < 0 || value > 1) {
|
||||||
fail(historyErrorCodes.invalidRandomValue);
|
fail(historyErrorCodes.invalidRandomValue);
|
||||||
}
|
}
|
||||||
const range = MAX_PAGE_DELAY_MS - MIN_PAGE_DELAY_MS;
|
const range = MAX_PAGE_DELAY_MS - MIN_PAGE_DELAY_MS;
|
||||||
return MIN_PAGE_DELAY_MS + Math.min(range, Math.floor(value * (range + 1)));
|
return MIN_PAGE_DELAY_MS + Math.min(range, Math.floor(value * (range + 1)));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runSync(
|
async function runSync(
|
||||||
pageWindow: OneTalkPageWindow,
|
pageWindow: OneTalkPageWindow,
|
||||||
dependencies: SyncDependencies,
|
dependencies: SyncDependencies,
|
||||||
): Promise<CurrentConversationHistorySyncResult> {
|
): Promise<CurrentConversationHistorySyncResult> {
|
||||||
const now = dependencies.now ?? Date.now;
|
const now = dependencies.now ?? Date.now;
|
||||||
const random = dependencies.random ?? Math.random;
|
const random = dependencies.random ?? Math.random;
|
||||||
const sleep =
|
const sleep =
|
||||||
dependencies.sleep ??
|
dependencies.sleep ??
|
||||||
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
||||||
|
|
||||||
const accountId = activeAccountId(pageWindow);
|
const accountId = activeAccountId(pageWindow);
|
||||||
const firstTimeStamp = now();
|
const firstTimeStamp = now();
|
||||||
if (!Number.isFinite(firstTimeStamp)) fail(historyErrorCodes.invalidTime);
|
if (!Number.isFinite(firstTimeStamp)) fail(historyErrorCodes.invalidTime);
|
||||||
const sdk = pageSdk(pageWindow);
|
const sdk = pageSdk(pageWindow);
|
||||||
const conversation = await findCurrentConversation(pageWindow, sdk, accountId);
|
const conversation = await findCurrentConversation(pageWindow, sdk, accountId);
|
||||||
assertActiveAccount(pageWindow, accountId);
|
|
||||||
const service = messageService(sdk);
|
|
||||||
|
|
||||||
const messageIds = new Set<string>();
|
|
||||||
let pages = 0;
|
|
||||||
let timeStamp = firstTimeStamp;
|
|
||||||
while (true) {
|
|
||||||
assertActiveAccount(pageWindow, accountId);
|
|
||||||
const page = await fetchMessagesWithoutUpdateToRead(service, conversation, timeStamp);
|
|
||||||
assertActiveAccount(pageWindow, accountId);
|
assertActiveAccount(pageWindow, accountId);
|
||||||
|
const service = messageService(sdk);
|
||||||
|
|
||||||
let oldestTime = Number.POSITIVE_INFINITY;
|
const messageIds = new Set<string>();
|
||||||
let newMessages = 0;
|
let pages = 0;
|
||||||
for (const message of page.messages) {
|
let timeStamp = firstTimeStamp;
|
||||||
oldestTime = Math.min(oldestTime, message.sendTime);
|
while (true) {
|
||||||
if (!messageIds.has(message.messageIdKey)) {
|
assertActiveAccount(pageWindow, accountId);
|
||||||
messageIds.add(message.messageIdKey);
|
const page = await fetchMessagesWithoutUpdateToRead(service, conversation, timeStamp);
|
||||||
newMessages += 1;
|
assertActiveAccount(pageWindow, accountId);
|
||||||
}
|
|
||||||
}
|
|
||||||
if (newMessages === 0 || oldestTime >= timeStamp) {
|
|
||||||
fail(historyErrorCodes.messageCursorStalled);
|
|
||||||
}
|
|
||||||
pages += 1;
|
|
||||||
if (!page.hasMore) {
|
|
||||||
return { exit: "history_exhausted", pages, uniqueMessages: messageIds.size };
|
|
||||||
}
|
|
||||||
|
|
||||||
timeStamp = oldestTime;
|
let oldestTime = Number.POSITIVE_INFINITY;
|
||||||
try {
|
let newMessages = 0;
|
||||||
await sleep(pageDelay(random));
|
for (const message of page.messages) {
|
||||||
} catch {
|
oldestTime = Math.min(oldestTime, message.sendTime);
|
||||||
fail(historyErrorCodes.sleepFailed);
|
if (!messageIds.has(message.messageIdKey)) {
|
||||||
|
messageIds.add(message.messageIdKey);
|
||||||
|
newMessages += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (newMessages === 0 || oldestTime >= timeStamp) {
|
||||||
|
fail(historyErrorCodes.messageCursorStalled);
|
||||||
|
}
|
||||||
|
pages += 1;
|
||||||
|
if (!page.hasMore) {
|
||||||
|
return { exit: "history_exhausted", pages, uniqueMessages: messageIds.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
timeStamp = oldestTime;
|
||||||
|
try {
|
||||||
|
await sleep(pageDelay(random));
|
||||||
|
} catch {
|
||||||
|
fail(historyErrorCodes.sleepFailed);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 完成当前会话历史同步并复用同页面进行中的请求。 */
|
/** 完成当前会话历史同步并复用同页面进行中的请求。 */
|
||||||
export function syncCurrentConversationHistory(
|
export function syncCurrentConversationHistory(
|
||||||
pageWindow: OneTalkPageWindow,
|
pageWindow: OneTalkPageWindow,
|
||||||
dependencies: SyncDependencies = {},
|
dependencies: SyncDependencies = {},
|
||||||
): Promise<CurrentConversationHistorySyncResult> {
|
): Promise<CurrentConversationHistorySyncResult> {
|
||||||
const existing = inFlightSyncs.get(pageWindow);
|
const existing = inFlightSyncs.get(pageWindow);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const sync = runSync(pageWindow, dependencies);
|
const sync = runSync(pageWindow, dependencies);
|
||||||
inFlightSyncs.set(pageWindow, sync);
|
inFlightSyncs.set(pageWindow, sync);
|
||||||
const clear = (): void => {
|
const clear = (): void => {
|
||||||
if (inFlightSyncs.get(pageWindow) === sync) inFlightSyncs.delete(pageWindow);
|
if (inFlightSyncs.get(pageWindow) === sync) inFlightSyncs.delete(pageWindow);
|
||||||
};
|
};
|
||||||
void sync.then(clear, clear);
|
void sync.then(clear, clear);
|
||||||
return sync;
|
return sync;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { CurrentConversationHistorySyncResult, SyncDependencies } from "./model.ts";
|
export type { CurrentConversationHistorySyncResult, SyncDependencies } from "./model.ts";
|
||||||
|
|||||||
@@ -5,143 +5,143 @@ import { isAccountId, type OneTalkAccountId } from "../model.ts";
|
|||||||
import { isRecord } from "../utils.ts";
|
import { isRecord } from "../utils.ts";
|
||||||
|
|
||||||
export const historyErrorCodes = {
|
export const historyErrorCodes = {
|
||||||
activeAccountChanged: "onetalk_history_active_account_changed",
|
activeAccountChanged: "onetalk_history_active_account_changed",
|
||||||
conversationCursorStalled: "onetalk_history_conversation_cursor_stalled",
|
conversationCursorStalled: "onetalk_history_conversation_cursor_stalled",
|
||||||
conversationRequestFailed: "onetalk_history_conversation_request_failed",
|
conversationRequestFailed: "onetalk_history_conversation_request_failed",
|
||||||
currentConversationNotFound: "onetalk_history_current_conversation_not_found",
|
currentConversationNotFound: "onetalk_history_current_conversation_not_found",
|
||||||
invalidActiveAccount: "onetalk_history_invalid_active_account",
|
invalidActiveAccount: "onetalk_history_invalid_active_account",
|
||||||
invalidConversationPage: "onetalk_history_invalid_conversation_page",
|
invalidConversationPage: "onetalk_history_invalid_conversation_page",
|
||||||
invalidMessagePage: "onetalk_history_invalid_message_page",
|
invalidMessagePage: "onetalk_history_invalid_message_page",
|
||||||
invalidRandomValue: "onetalk_history_invalid_random_value",
|
invalidRandomValue: "onetalk_history_invalid_random_value",
|
||||||
invalidTime: "onetalk_history_invalid_time",
|
invalidTime: "onetalk_history_invalid_time",
|
||||||
messageCursorStalled: "onetalk_history_message_cursor_stalled",
|
messageCursorStalled: "onetalk_history_message_cursor_stalled",
|
||||||
messageRequestFailed: "onetalk_history_message_request_failed",
|
messageRequestFailed: "onetalk_history_message_request_failed",
|
||||||
sdkUnavailable: "onetalk_history_sdk_unavailable",
|
sdkUnavailable: "onetalk_history_sdk_unavailable",
|
||||||
sleepFailed: "onetalk_history_sleep_failed",
|
sleepFailed: "onetalk_history_sleep_failed",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type HistoryErrorCode = (typeof historyErrorCodes)[keyof typeof historyErrorCodes];
|
export type HistoryErrorCode = (typeof historyErrorCodes)[keyof typeof historyErrorCodes];
|
||||||
export type Cursor = string | number;
|
export type Cursor = string | number;
|
||||||
|
|
||||||
export type Conversation = Record<string, unknown> & {
|
export type Conversation = Record<string, unknown> & {
|
||||||
cid: string;
|
cid: string;
|
||||||
accountId: OneTalkAccountId;
|
accountId: OneTalkAccountId;
|
||||||
accountIdEncrypt: string;
|
accountIdEncrypt: string;
|
||||||
aliId: string;
|
aliId: string;
|
||||||
aliIdEncrypt?: string;
|
aliIdEncrypt?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ConversationPage = {
|
export type ConversationPage = {
|
||||||
list: unknown[];
|
list: unknown[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
nextCursor?: unknown;
|
nextCursor?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HistoryMessage = {
|
export type HistoryMessage = {
|
||||||
messageIdKey: string;
|
messageIdKey: string;
|
||||||
sendTime: number;
|
sendTime: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HistoryMessagePage = {
|
export type HistoryMessagePage = {
|
||||||
messages: HistoryMessage[];
|
messages: HistoryMessage[];
|
||||||
hasMore: boolean;
|
hasMore: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SyncDependencies = {
|
export type SyncDependencies = {
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
random?: () => number;
|
random?: () => number;
|
||||||
sleep?: (milliseconds: number) => Promise<void>;
|
sleep?: (milliseconds: number) => Promise<void>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CurrentConversationHistorySyncResult = {
|
export type CurrentConversationHistorySyncResult = {
|
||||||
exit: "history_exhausted";
|
exit: "history_exhausted";
|
||||||
pages: number;
|
pages: number;
|
||||||
uniqueMessages: number;
|
uniqueMessages: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function normalizeActiveAccountId(value: string | null): string {
|
export function normalizeActiveAccountId(value: string | null): string {
|
||||||
return value || fail(historyErrorCodes.invalidActiveAccount);
|
return value || fail(historyErrorCodes.invalidActiveAccount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCursor(value: unknown): value is Cursor {
|
export function isCursor(value: unknown): value is Cursor {
|
||||||
return (
|
return (
|
||||||
(typeof value === "string" && value.length > 0) ||
|
(typeof value === "string" && value.length > 0) ||
|
||||||
(typeof value === "number" && Number.isFinite(value))
|
(typeof value === "number" && Number.isFinite(value))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePageSdk(value: unknown): Record<string, unknown> {
|
export function normalizePageSdk(value: unknown): Record<string, unknown> {
|
||||||
if (!isRecord(value) || !isRecord(value.IMBaaSSDK) || !isRecord(value.IMBaaSSDK.default)) {
|
if (!isRecord(value) || !isRecord(value.IMBaaSSDK) || !isRecord(value.IMBaaSSDK.default)) {
|
||||||
fail(historyErrorCodes.sdkUnavailable);
|
fail(historyErrorCodes.sdkUnavailable);
|
||||||
}
|
}
|
||||||
return value.IMBaaSSDK.default;
|
return value.IMBaaSSDK.default;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeSdkService(value: unknown): Record<string, unknown> {
|
export function normalizeSdkService(value: unknown): Record<string, unknown> {
|
||||||
return isRecord(value) ? value : fail(historyErrorCodes.sdkUnavailable);
|
return isRecord(value) ? value : fail(historyErrorCodes.sdkUnavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeConversation(value: unknown, accountId: string): Conversation | null {
|
function normalizeConversation(value: unknown, accountId: string): Conversation | null {
|
||||||
if (!isRecord(value)) fail(historyErrorCodes.invalidConversationPage);
|
if (!isRecord(value)) fail(historyErrorCodes.invalidConversationPage);
|
||||||
if (!isAccountId(value.accountId) || String(value.accountId) !== accountId) return null;
|
if (!isAccountId(value.accountId) || String(value.accountId) !== accountId) return null;
|
||||||
if (
|
if (
|
||||||
typeof value.cid !== "string" ||
|
typeof value.cid !== "string" ||
|
||||||
value.cid.length === 0 ||
|
value.cid.length === 0 ||
|
||||||
typeof value.accountIdEncrypt !== "string" ||
|
typeof value.accountIdEncrypt !== "string" ||
|
||||||
value.accountIdEncrypt.length === 0 ||
|
value.accountIdEncrypt.length === 0 ||
|
||||||
typeof value.aliId !== "string" ||
|
typeof value.aliId !== "string" ||
|
||||||
value.aliId.length === 0 ||
|
value.aliId.length === 0 ||
|
||||||
(value.aliIdEncrypt !== undefined &&
|
(value.aliIdEncrypt !== undefined &&
|
||||||
(typeof value.aliIdEncrypt !== "string" || value.aliIdEncrypt.length === 0))
|
(typeof value.aliIdEncrypt !== "string" || value.aliIdEncrypt.length === 0))
|
||||||
) {
|
) {
|
||||||
fail(historyErrorCodes.invalidConversationPage);
|
fail(historyErrorCodes.invalidConversationPage);
|
||||||
}
|
}
|
||||||
return value as Conversation;
|
return value as Conversation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeConversationPage(value: unknown): ConversationPage {
|
export function normalizeConversationPage(value: unknown): ConversationPage {
|
||||||
if (!isRecord(value) || !Array.isArray(value.list) || typeof value.hasMore !== "boolean") {
|
if (!isRecord(value) || !Array.isArray(value.list) || typeof value.hasMore !== "boolean") {
|
||||||
fail(historyErrorCodes.invalidConversationPage);
|
fail(historyErrorCodes.invalidConversationPage);
|
||||||
}
|
}
|
||||||
return { list: value.list, hasMore: value.hasMore, nextCursor: value.nextCursor };
|
return { list: value.list, hasMore: value.hasMore, nextCursor: value.nextCursor };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findMatchingConversation(
|
export function findMatchingConversation(
|
||||||
values: unknown[],
|
values: unknown[],
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): Conversation | null {
|
): Conversation | null {
|
||||||
for (const value of values) {
|
for (const value of values) {
|
||||||
const conversation = normalizeConversation(value, accountId);
|
const conversation = normalizeConversation(value, accountId);
|
||||||
if (conversation) return conversation;
|
if (conversation) return conversation;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeHistoryMessage(value: unknown): HistoryMessage {
|
function normalizeHistoryMessage(value: unknown): HistoryMessage {
|
||||||
if (!isRecord(value)) fail(historyErrorCodes.invalidMessagePage);
|
if (!isRecord(value)) fail(historyErrorCodes.invalidMessagePage);
|
||||||
const messageId = value.messageId;
|
const messageId = value.messageId;
|
||||||
const sendTime = value.sendTime;
|
const sendTime = value.sendTime;
|
||||||
if (
|
if (
|
||||||
!(
|
!(
|
||||||
(typeof messageId === "string" && messageId.length > 0) ||
|
(typeof messageId === "string" && messageId.length > 0) ||
|
||||||
(typeof messageId === "number" && Number.isFinite(messageId))
|
(typeof messageId === "number" && Number.isFinite(messageId))
|
||||||
) ||
|
) ||
|
||||||
typeof sendTime !== "number" ||
|
typeof sendTime !== "number" ||
|
||||||
!Number.isFinite(sendTime)
|
!Number.isFinite(sendTime)
|
||||||
) {
|
) {
|
||||||
fail(historyErrorCodes.invalidMessagePage);
|
fail(historyErrorCodes.invalidMessagePage);
|
||||||
}
|
}
|
||||||
return { messageIdKey: String(messageId), sendTime };
|
return { messageIdKey: String(messageId), sendTime };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeHistoryMessagePage(value: unknown): HistoryMessagePage {
|
export function normalizeHistoryMessagePage(value: unknown): HistoryMessagePage {
|
||||||
if (
|
if (
|
||||||
!isRecord(value) ||
|
!isRecord(value) ||
|
||||||
!Array.isArray(value.list) ||
|
!Array.isArray(value.list) ||
|
||||||
value.list.length === 0 ||
|
value.list.length === 0 ||
|
||||||
typeof value.hasMore !== "boolean"
|
typeof value.hasMore !== "boolean"
|
||||||
) {
|
) {
|
||||||
fail(historyErrorCodes.invalidMessagePage);
|
fail(historyErrorCodes.invalidMessagePage);
|
||||||
}
|
}
|
||||||
return { messages: value.list.map(normalizeHistoryMessage), hasMore: value.hasMore };
|
return { messages: value.list.map(normalizeHistoryMessage), hasMore: value.hasMore };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,131 +2,131 @@
|
|||||||
|
|
||||||
import { fail } from "../../../lib/error.ts";
|
import { fail } from "../../../lib/error.ts";
|
||||||
import {
|
import {
|
||||||
findMatchingConversation,
|
findMatchingConversation,
|
||||||
historyErrorCodes,
|
historyErrorCodes,
|
||||||
isCursor,
|
isCursor,
|
||||||
normalizeActiveAccountId,
|
normalizeActiveAccountId,
|
||||||
normalizeConversationPage,
|
normalizeConversationPage,
|
||||||
normalizeHistoryMessagePage,
|
normalizeHistoryMessagePage,
|
||||||
normalizePageSdk,
|
normalizePageSdk,
|
||||||
normalizeSdkService,
|
normalizeSdkService,
|
||||||
type Conversation,
|
type Conversation,
|
||||||
type Cursor,
|
type Cursor,
|
||||||
type HistoryMessagePage,
|
type HistoryMessagePage,
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
import type { OneTalkPageWindow } from "../model.ts";
|
import type { OneTalkPageWindow } from "../model.ts";
|
||||||
|
|
||||||
const HISTORY_PAGE_SIZE = 20;
|
const HISTORY_PAGE_SIZE = 20;
|
||||||
|
|
||||||
function conversationCursorKey(cursor: Cursor): string {
|
function conversationCursorKey(cursor: Cursor): string {
|
||||||
return `${typeof cursor}:${cursor}`;
|
return `${typeof cursor}:${cursor}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function activeAccountId(pageWindow: OneTalkPageWindow): string {
|
export function activeAccountId(pageWindow: OneTalkPageWindow): string {
|
||||||
try {
|
try {
|
||||||
return normalizeActiveAccountId(
|
return normalizeActiveAccountId(
|
||||||
new URL(pageWindow.location.href).searchParams.get("activeAccountId"),
|
new URL(pageWindow.location.href).searchParams.get("activeAccountId"),
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === historyErrorCodes.invalidActiveAccount) {
|
if (error instanceof Error && error.message === historyErrorCodes.invalidActiveAccount) {
|
||||||
throw error;
|
throw error;
|
||||||
|
}
|
||||||
|
return fail(historyErrorCodes.invalidActiveAccount);
|
||||||
}
|
}
|
||||||
return fail(historyErrorCodes.invalidActiveAccount);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function assertActiveAccount(pageWindow: OneTalkPageWindow, expected: string): void {
|
export function assertActiveAccount(pageWindow: OneTalkPageWindow, expected: string): void {
|
||||||
if (activeAccountId(pageWindow) !== expected) {
|
if (activeAccountId(pageWindow) !== expected) {
|
||||||
fail(historyErrorCodes.activeAccountChanged);
|
fail(historyErrorCodes.activeAccountChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pageSdk(pageWindow: OneTalkPageWindow): Record<string, unknown> {
|
export function pageSdk(pageWindow: OneTalkPageWindow): Record<string, unknown> {
|
||||||
return normalizePageSdk(pageWindow.IcbuIM);
|
return normalizePageSdk(pageWindow.IcbuIM);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function messageService(sdk: Record<string, unknown>): Record<string, unknown> {
|
export function messageService(sdk: Record<string, unknown>): Record<string, unknown> {
|
||||||
const getMessageService = sdk.getMessageService;
|
const getMessageService = sdk.getMessageService;
|
||||||
if (typeof getMessageService !== "function") {
|
if (typeof getMessageService !== "function") {
|
||||||
fail(historyErrorCodes.sdkUnavailable);
|
fail(historyErrorCodes.sdkUnavailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return normalizeSdkService(getMessageService.call(sdk));
|
return normalizeSdkService(getMessageService.call(sdk));
|
||||||
} catch {
|
} catch {
|
||||||
return fail(historyErrorCodes.sdkUnavailable);
|
return fail(historyErrorCodes.sdkUnavailable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchMessagesWithoutUpdateToRead(
|
export async function fetchMessagesWithoutUpdateToRead(
|
||||||
service: Record<string, unknown>,
|
service: Record<string, unknown>,
|
||||||
conversation: Conversation,
|
conversation: Conversation,
|
||||||
timeStamp: number,
|
timeStamp: number,
|
||||||
): Promise<HistoryMessagePage> {
|
): Promise<HistoryMessagePage> {
|
||||||
const fetchPage = service.fetchMessagesWithoutUpdateToRead;
|
const fetchPage = service.fetchMessagesWithoutUpdateToRead;
|
||||||
if (typeof fetchPage !== "function") fail(historyErrorCodes.sdkUnavailable);
|
if (typeof fetchPage !== "function") fail(historyErrorCodes.sdkUnavailable);
|
||||||
|
|
||||||
let response: unknown;
|
let response: unknown;
|
||||||
try {
|
try {
|
||||||
const aliIdEncrypt = conversation.aliIdEncrypt;
|
const aliIdEncrypt = conversation.aliIdEncrypt;
|
||||||
response = await fetchPage.call(
|
response = await fetchPage.call(
|
||||||
service,
|
service,
|
||||||
{
|
{
|
||||||
contactAccountId: conversation.accountId,
|
contactAccountId: conversation.accountId,
|
||||||
contactAccountIdEncrypt: conversation.accountIdEncrypt,
|
contactAccountIdEncrypt: conversation.accountIdEncrypt,
|
||||||
aliId: conversation.aliId,
|
aliId: conversation.aliId,
|
||||||
...(aliIdEncrypt === undefined ? {} : { aliIdEncrypt }),
|
...(aliIdEncrypt === undefined ? {} : { aliIdEncrypt }),
|
||||||
searchMessageId: "",
|
searchMessageId: "",
|
||||||
timeSlide: { forward: false, timeStamp, pageSize: HISTORY_PAGE_SIZE },
|
timeSlide: { forward: false, timeStamp, pageSize: HISTORY_PAGE_SIZE },
|
||||||
},
|
},
|
||||||
conversation,
|
conversation,
|
||||||
);
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return fail(historyErrorCodes.messageRequestFailed);
|
return fail(historyErrorCodes.messageRequestFailed);
|
||||||
}
|
}
|
||||||
return normalizeHistoryMessagePage(response);
|
return normalizeHistoryMessagePage(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 扫描会话分页并返回与当前账号匹配的会话。 */
|
/** 扫描会话分页并返回与当前账号匹配的会话。 */
|
||||||
export async function findCurrentConversation(
|
export async function findCurrentConversation(
|
||||||
pageWindow: OneTalkPageWindow,
|
pageWindow: OneTalkPageWindow,
|
||||||
sdk: Record<string, unknown>,
|
sdk: Record<string, unknown>,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
): Promise<Conversation> {
|
): Promise<Conversation> {
|
||||||
const getService = sdk.getConversationServiceV2;
|
const getService = sdk.getConversationServiceV2;
|
||||||
if (typeof getService !== "function") fail(historyErrorCodes.sdkUnavailable);
|
if (typeof getService !== "function") fail(historyErrorCodes.sdkUnavailable);
|
||||||
|
|
||||||
let service: Record<string, unknown>;
|
let service: Record<string, unknown>;
|
||||||
try {
|
|
||||||
service = normalizeSdkService(getService.call(sdk));
|
|
||||||
} catch {
|
|
||||||
return fail(historyErrorCodes.sdkUnavailable);
|
|
||||||
}
|
|
||||||
const getPage = service.getConversationListByPagination;
|
|
||||||
if (typeof getPage !== "function") fail(historyErrorCodes.sdkUnavailable);
|
|
||||||
|
|
||||||
let cursor: Cursor = 0;
|
|
||||||
const seenCursors = new Set<string>();
|
|
||||||
while (true) {
|
|
||||||
assertActiveAccount(pageWindow, accountId);
|
|
||||||
const key = conversationCursorKey(cursor);
|
|
||||||
if (seenCursors.has(key)) fail(historyErrorCodes.conversationCursorStalled);
|
|
||||||
seenCursors.add(key);
|
|
||||||
|
|
||||||
let response: unknown;
|
|
||||||
try {
|
try {
|
||||||
response = await getPage.call(service, { cursor, count: HISTORY_PAGE_SIZE });
|
service = normalizeSdkService(getService.call(sdk));
|
||||||
} catch {
|
} catch {
|
||||||
return fail(historyErrorCodes.conversationRequestFailed);
|
return fail(historyErrorCodes.sdkUnavailable);
|
||||||
}
|
}
|
||||||
assertActiveAccount(pageWindow, accountId);
|
const getPage = service.getConversationListByPagination;
|
||||||
const page = normalizeConversationPage(response);
|
if (typeof getPage !== "function") fail(historyErrorCodes.sdkUnavailable);
|
||||||
const conversation = findMatchingConversation(page.list, accountId);
|
|
||||||
if (conversation) return conversation;
|
let cursor: Cursor = 0;
|
||||||
if (!page.hasMore) fail(historyErrorCodes.currentConversationNotFound);
|
const seenCursors = new Set<string>();
|
||||||
if (!isCursor(page.nextCursor)) {
|
while (true) {
|
||||||
fail(historyErrorCodes.conversationCursorStalled);
|
assertActiveAccount(pageWindow, accountId);
|
||||||
|
const key = conversationCursorKey(cursor);
|
||||||
|
if (seenCursors.has(key)) fail(historyErrorCodes.conversationCursorStalled);
|
||||||
|
seenCursors.add(key);
|
||||||
|
|
||||||
|
let response: unknown;
|
||||||
|
try {
|
||||||
|
response = await getPage.call(service, { cursor, count: HISTORY_PAGE_SIZE });
|
||||||
|
} catch {
|
||||||
|
return fail(historyErrorCodes.conversationRequestFailed);
|
||||||
|
}
|
||||||
|
assertActiveAccount(pageWindow, accountId);
|
||||||
|
const page = normalizeConversationPage(response);
|
||||||
|
const conversation = findMatchingConversation(page.list, accountId);
|
||||||
|
if (conversation) return conversation;
|
||||||
|
if (!page.hasMore) fail(historyErrorCodes.currentConversationNotFound);
|
||||||
|
if (!isCursor(page.nextCursor)) {
|
||||||
|
fail(historyErrorCodes.conversationCursorStalled);
|
||||||
|
}
|
||||||
|
cursor = page.nextCursor;
|
||||||
}
|
}
|
||||||
cursor = page.nextCursor;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import { installOneTalkWebSocketTap, type OneTalkWebSocketPageWindow } from "./w
|
|||||||
|
|
||||||
/** 组合并安装 OneTalk 消息观察入口。 */
|
/** 组合并安装 OneTalk 消息观察入口。 */
|
||||||
export function installOneTalkMessageObserver(pageWindow: OneTalkWebSocketPageWindow): void {
|
export function installOneTalkMessageObserver(pageWindow: OneTalkWebSocketPageWindow): void {
|
||||||
installOneTalkWebSocketTap(pageWindow);
|
installOneTalkWebSocketTap(pageWindow);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,18 @@ import { conversationParticipants, observedMessage, type ObservedOneTalkMessage
|
|||||||
|
|
||||||
/** 将历史消息响应条目转换为可观察消息。 */
|
/** 将历史消息响应条目转换为可观察消息。 */
|
||||||
export function parseHistoryMessages(items: unknown[]): ObservedOneTalkMessage[] {
|
export function parseHistoryMessages(items: unknown[]): ObservedOneTalkMessage[] {
|
||||||
return items.flatMap((item) => {
|
return items.flatMap((item) => {
|
||||||
if (!isRecord(item) || !isRecord(item.message)) return [];
|
if (!isRecord(item) || !isRecord(item.message)) return [];
|
||||||
const participantIds = conversationParticipants(item.message.cid);
|
const participantIds = conversationParticipants(item.message.cid);
|
||||||
if (!participantIds) return [];
|
if (!participantIds) return [];
|
||||||
|
|
||||||
const message = observedMessage(
|
const message = observedMessage(
|
||||||
item.message,
|
item.message,
|
||||||
participantIds,
|
participantIds,
|
||||||
item.readStatus,
|
item.readStatus,
|
||||||
item.msgStatus,
|
item.msgStatus,
|
||||||
"history",
|
"history",
|
||||||
);
|
);
|
||||||
return message ? [message] : [];
|
return message ? [message] : [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,20 @@ import type { ObservedOneTalkMessage } from "./model.ts";
|
|||||||
|
|
||||||
/** 识别响应帧并委派到历史或新消息解析器。 */
|
/** 识别响应帧并委派到历史或新消息解析器。 */
|
||||||
export function parseOneTalkMessages(data: unknown): ObservedOneTalkMessage[] {
|
export function parseOneTalkMessages(data: unknown): ObservedOneTalkMessage[] {
|
||||||
if (typeof data !== "string") return [];
|
if (typeof data !== "string") return [];
|
||||||
|
|
||||||
let frame: unknown;
|
let frame: unknown;
|
||||||
try {
|
try {
|
||||||
frame = JSON.parse(data);
|
frame = JSON.parse(data);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRecord(frame) || frame.code !== 200) return [];
|
if (!isRecord(frame) || frame.code !== 200) return [];
|
||||||
|
|
||||||
if (isRecord(frame.body) && Array.isArray(frame.body.userMessageModels)) {
|
if (isRecord(frame.body) && Array.isArray(frame.body.userMessageModels)) {
|
||||||
return parseHistoryMessages(frame.body.userMessageModels);
|
return parseHistoryMessages(frame.body.userMessageModels);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.isArray(frame.body) ? parseNewMessages(frame.body) : [];
|
return Array.isArray(frame.body) ? parseNewMessages(frame.body) : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,71 +3,71 @@
|
|||||||
import { isRecord } from "../utils.ts";
|
import { isRecord } from "../utils.ts";
|
||||||
|
|
||||||
export interface ObservedOneTalkMessage {
|
export interface ObservedOneTalkMessage {
|
||||||
messageType: "new" | "history";
|
messageType: "new" | "history";
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
sentAt: number;
|
sentAt: number;
|
||||||
contentType: number;
|
contentType: number;
|
||||||
text: string | null;
|
text: string | null;
|
||||||
senderId: string;
|
senderId: string;
|
||||||
participantIds: [string, string];
|
participantIds: [string, string];
|
||||||
direction: "sent" | "received";
|
direction: "sent" | "received";
|
||||||
readStatus: number;
|
readStatus: number;
|
||||||
messageStatus: number;
|
messageStatus: number;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function conversationParticipants(value: unknown): [string, string] | null {
|
export function conversationParticipants(value: unknown): [string, string] | null {
|
||||||
if (typeof value !== "string") return null;
|
if (typeof value !== "string") return null;
|
||||||
const match = /^([^-]+)-([^#]+)#[^@]+@(.+)$/.exec(value);
|
const match = /^([^-]+)-([^#]+)#[^@]+@(.+)$/.exec(value);
|
||||||
return match ? [`${match[1]}@${match[3]}`, `${match[2]}@${match[3]}`] : null;
|
return match ? [`${match[1]}@${match[3]}`, `${match[2]}@${match[3]}`] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 校验消息字段并生成统一的观察消息模型。 */
|
/** 校验消息字段并生成统一的观察消息模型。 */
|
||||||
export function observedMessage(
|
export function observedMessage(
|
||||||
message: Record<string, unknown>,
|
message: Record<string, unknown>,
|
||||||
participantIds: [string, string],
|
participantIds: [string, string],
|
||||||
readStatus: unknown,
|
readStatus: unknown,
|
||||||
messageStatus: unknown,
|
messageStatus: unknown,
|
||||||
messageType: ObservedOneTalkMessage["messageType"],
|
messageType: ObservedOneTalkMessage["messageType"],
|
||||||
): ObservedOneTalkMessage | null {
|
): ObservedOneTalkMessage | null {
|
||||||
if (!isRecord(message.content) || !isRecord(message.sender)) return null;
|
if (!isRecord(message.content) || !isRecord(message.sender)) return null;
|
||||||
const text = message.content.text;
|
const text = message.content.text;
|
||||||
|
|
||||||
const conversationId = message.cid;
|
const conversationId = message.cid;
|
||||||
const messageId = message.messageId;
|
const messageId = message.messageId;
|
||||||
const sentAt = message.createAt;
|
const sentAt = message.createAt;
|
||||||
const contentType = message.content.contentType;
|
const contentType = message.content.contentType;
|
||||||
const content = isRecord(text) && typeof text.content === "string" ? text.content : null;
|
const content = isRecord(text) && typeof text.content === "string" ? text.content : null;
|
||||||
const senderId = message.sender.uid;
|
const senderId = message.sender.uid;
|
||||||
const unreadCount = message.unreadCount;
|
const unreadCount = message.unreadCount;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof conversationId !== "string" ||
|
typeof conversationId !== "string" ||
|
||||||
typeof messageId !== "string" ||
|
typeof messageId !== "string" ||
|
||||||
typeof sentAt !== "number" ||
|
typeof sentAt !== "number" ||
|
||||||
typeof contentType !== "number" ||
|
typeof contentType !== "number" ||
|
||||||
typeof senderId !== "string" ||
|
typeof senderId !== "string" ||
|
||||||
!participantIds.includes(senderId) ||
|
!participantIds.includes(senderId) ||
|
||||||
typeof readStatus !== "number" ||
|
typeof readStatus !== "number" ||
|
||||||
typeof messageStatus !== "number" ||
|
typeof messageStatus !== "number" ||
|
||||||
typeof unreadCount !== "number"
|
typeof unreadCount !== "number"
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
messageType,
|
messageType,
|
||||||
conversationId,
|
conversationId,
|
||||||
messageId,
|
messageId,
|
||||||
sentAt,
|
sentAt,
|
||||||
contentType,
|
contentType,
|
||||||
text: content,
|
text: content,
|
||||||
senderId,
|
senderId,
|
||||||
participantIds,
|
participantIds,
|
||||||
direction: senderId === participantIds[1] ? "sent" : "received",
|
direction: senderId === participantIds[1] ? "sent" : "received",
|
||||||
readStatus,
|
readStatus,
|
||||||
messageStatus,
|
messageStatus,
|
||||||
unreadCount,
|
unreadCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,37 +4,37 @@ import { isRecord } from "../utils.ts";
|
|||||||
import { observedMessage, type ObservedOneTalkMessage } from "./model.ts";
|
import { observedMessage, type ObservedOneTalkMessage } from "./model.ts";
|
||||||
|
|
||||||
function parseNewMessage(conversation: Record<string, unknown>): ObservedOneTalkMessage | null {
|
function parseNewMessage(conversation: Record<string, unknown>): ObservedOneTalkMessage | null {
|
||||||
const lastMessage = conversation.lastMessage;
|
const lastMessage = conversation.lastMessage;
|
||||||
const singleChatConversation = conversation.singleChatConversation;
|
const singleChatConversation = conversation.singleChatConversation;
|
||||||
if (!isRecord(lastMessage) || !isRecord(singleChatConversation)) return null;
|
if (!isRecord(lastMessage) || !isRecord(singleChatConversation)) return null;
|
||||||
|
|
||||||
const pairFirst = singleChatConversation.pairFirst;
|
const pairFirst = singleChatConversation.pairFirst;
|
||||||
const pairSecond = singleChatConversation.pairSecond;
|
const pairSecond = singleChatConversation.pairSecond;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof pairFirst !== "string" ||
|
typeof pairFirst !== "string" ||
|
||||||
typeof pairSecond !== "string" ||
|
typeof pairSecond !== "string" ||
|
||||||
!isRecord(lastMessage.message)
|
!isRecord(lastMessage.message)
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return observedMessage(
|
return observedMessage(
|
||||||
lastMessage.message,
|
lastMessage.message,
|
||||||
[pairFirst, pairSecond],
|
[pairFirst, pairSecond],
|
||||||
lastMessage.readStatus,
|
lastMessage.readStatus,
|
||||||
lastMessage.msgStatus,
|
lastMessage.msgStatus,
|
||||||
"new",
|
"new",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将新消息响应条目转换为可观察消息。 */
|
/** 将新消息响应条目转换为可观察消息。 */
|
||||||
export function parseNewMessages(items: unknown[]): ObservedOneTalkMessage[] {
|
export function parseNewMessages(items: unknown[]): ObservedOneTalkMessage[] {
|
||||||
const messages: ObservedOneTalkMessage[] = [];
|
const messages: ObservedOneTalkMessage[] = [];
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
if (!isRecord(item) || !isRecord(item.singleChatUserConversation)) continue;
|
if (!isRecord(item) || !isRecord(item.singleChatUserConversation)) continue;
|
||||||
const message = parseNewMessage(item.singleChatUserConversation);
|
const message = parseNewMessage(item.singleChatUserConversation);
|
||||||
if (message) messages.push(message);
|
if (message) messages.push(message);
|
||||||
}
|
}
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// 接入 OneTalk WebSocket 并旁路记录消息
|
// 接入 OneTalk WebSocket 并旁路记录消息
|
||||||
|
|
||||||
import {
|
import {
|
||||||
observeNewWebSocketMessages,
|
observeNewWebSocketMessages,
|
||||||
type WebSocketHost,
|
type WebSocketHost,
|
||||||
} from "../../../lib/websocket-observer.ts";
|
} from "../../../lib/websocket-observer.ts";
|
||||||
import type { OneTalkPageWindow } from "../model.ts";
|
import type { OneTalkPageWindow } from "../model.ts";
|
||||||
import { parseOneTalkMessages } from "./index.ts";
|
import { parseOneTalkMessages } from "./index.ts";
|
||||||
@@ -12,39 +12,39 @@ const LOG_PREFIX = "[Trade Message Center][OneTalk WebSocket]";
|
|||||||
const ONETALK_WEBSOCKET_HOST = "wss-icbu.dingtalk.com";
|
const ONETALK_WEBSOCKET_HOST = "wss-icbu.dingtalk.com";
|
||||||
|
|
||||||
export interface OneTalkWebSocketPageWindow extends OneTalkPageWindow, WebSocketHost {
|
export interface OneTalkWebSocketPageWindow extends OneTalkPageWindow, WebSocketHost {
|
||||||
[INSTALL_KEY]?: boolean;
|
[INSTALL_KEY]?: boolean;
|
||||||
console: Pick<Console, "log">;
|
console: Pick<Console, "log">;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWebSocketUrlForHost(url: string, hostname: string): boolean {
|
function isWebSocketUrlForHost(url: string, hostname: string): boolean {
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(url);
|
const parsed = new URL(url);
|
||||||
return parsed.protocol === "wss:" && parsed.hostname === hostname;
|
return parsed.protocol === "wss:" && parsed.hostname === hostname;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 安装只观察指定 OneTalk 主机的 WebSocket 旁路。 */
|
/** 安装只观察指定 OneTalk 主机的 WebSocket 旁路。 */
|
||||||
export function installOneTalkWebSocketTap(pageWindow: OneTalkWebSocketPageWindow): void {
|
export function installOneTalkWebSocketTap(pageWindow: OneTalkWebSocketPageWindow): void {
|
||||||
if (pageWindow[INSTALL_KEY]) return;
|
if (pageWindow[INSTALL_KEY]) return;
|
||||||
pageWindow[INSTALL_KEY] = true;
|
pageWindow[INSTALL_KEY] = true;
|
||||||
|
|
||||||
observeNewWebSocketMessages(
|
observeNewWebSocketMessages(
|
||||||
pageWindow,
|
pageWindow,
|
||||||
(url) => isWebSocketUrlForHost(url, ONETALK_WEBSOCKET_HOST),
|
(url) => isWebSocketUrlForHost(url, ONETALK_WEBSOCKET_HOST),
|
||||||
(data) => {
|
(data) => {
|
||||||
try {
|
try {
|
||||||
pageWindow.console.log(LOG_PREFIX, data);
|
pageWindow.console.log(LOG_PREFIX, data);
|
||||||
for (const message of parseOneTalkMessages(data)) {
|
for (const message of parseOneTalkMessages(data)) {
|
||||||
pageWindow.console.log(
|
pageWindow.console.log(
|
||||||
`[Trade Message Center][OneTalk ${message.messageType} message]`,
|
`[Trade Message Center][OneTalk ${message.messageType} message]`,
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Logging must never affect the page's WebSocket behavior.
|
// Logging must never affect the page's WebSocket behavior.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,15 +3,15 @@
|
|||||||
export type OneTalkAccountId = string | number;
|
export type OneTalkAccountId = string | number;
|
||||||
|
|
||||||
export type OneTalkPageWindow = {
|
export type OneTalkPageWindow = {
|
||||||
location: Pick<Location, "href">;
|
location: Pick<Location, "href">;
|
||||||
IcbuIM?: unknown;
|
IcbuIM?: unknown;
|
||||||
__tradeMessageCenterOneTalk?: unknown;
|
__tradeMessageCenterOneTalk?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** 判断值是否符合 OneTalk 账号标识形状。 */
|
/** 判断值是否符合 OneTalk 账号标识形状。 */
|
||||||
export function isAccountId(value: unknown): value is OneTalkAccountId {
|
export function isAccountId(value: unknown): value is OneTalkAccountId {
|
||||||
return (
|
return (
|
||||||
(typeof value === "string" && value.length > 0) ||
|
(typeof value === "string" && value.length > 0) ||
|
||||||
(typeof value === "number" && Number.isFinite(value))
|
(typeof value === "number" && Number.isFinite(value))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { installOneTalkMessageObserver } from "./message-observer/entry.ts";
|
|||||||
|
|
||||||
/** 启动 main-page 目录下的全部 OneTalk 页面能力。 */
|
/** 启动 main-page 目录下的全部 OneTalk 页面能力。 */
|
||||||
function installOneTalkPageFeatures(): void {
|
function installOneTalkPageFeatures(): void {
|
||||||
installOneTalkMessageObserver(window);
|
installOneTalkMessageObserver(window);
|
||||||
installCurrentConversationHistorySync(window);
|
installCurrentConversationHistorySync(window);
|
||||||
}
|
}
|
||||||
|
|
||||||
installOneTalkPageFeatures();
|
installOneTalkPageFeatures();
|
||||||
|
|||||||
@@ -1,203 +1,203 @@
|
|||||||
// 解码 OneTalk 的同步推送数据
|
// 解码 OneTalk 的同步推送数据
|
||||||
|
|
||||||
export type MessagePackValue =
|
export type MessagePackValue =
|
||||||
| null
|
| null
|
||||||
| boolean
|
| boolean
|
||||||
| number
|
| number
|
||||||
| bigint
|
| bigint
|
||||||
| string
|
| string
|
||||||
| Uint8Array
|
| Uint8Array
|
||||||
| MessagePackValue[]
|
| MessagePackValue[]
|
||||||
| { [key: string]: MessagePackValue };
|
| { [key: string]: MessagePackValue };
|
||||||
|
|
||||||
class MessagePackDecoder {
|
class MessagePackDecoder {
|
||||||
private readonly bytes: Uint8Array;
|
private readonly bytes: Uint8Array;
|
||||||
private readonly view: DataView;
|
private readonly view: DataView;
|
||||||
private readonly textDecoder = new TextDecoder("utf-8", { fatal: true });
|
private readonly textDecoder = new TextDecoder("utf-8", { fatal: true });
|
||||||
private offset = 0;
|
private offset = 0;
|
||||||
|
|
||||||
constructor(bytes: Uint8Array) {
|
constructor(bytes: Uint8Array) {
|
||||||
this.bytes = bytes;
|
this.bytes = bytes;
|
||||||
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||||
}
|
|
||||||
|
|
||||||
get done(): boolean {
|
|
||||||
return this.offset === this.bytes.byteLength;
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeArray(length: number): MessagePackValue[] {
|
|
||||||
return Array.from({ length }, () => this.decode());
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeMap(length: number): { [key: string]: MessagePackValue } {
|
|
||||||
const result: { [key: string]: MessagePackValue } = {};
|
|
||||||
|
|
||||||
for (let index = 0; index < length; index += 1) {
|
|
||||||
const key = this.decode();
|
|
||||||
if (typeof key !== "string" && typeof key !== "number" && typeof key !== "bigint") {
|
|
||||||
throw new Error("Unsupported MessagePack map key");
|
|
||||||
}
|
|
||||||
result[String(key)] = this.decode();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
get done(): boolean {
|
||||||
}
|
return this.offset === this.bytes.byteLength;
|
||||||
|
|
||||||
private decodeString(length: number): string {
|
|
||||||
return this.textDecoder.decode(this.readBytes(length));
|
|
||||||
}
|
|
||||||
|
|
||||||
private readBytes(length: number): Uint8Array {
|
|
||||||
this.ensureAvailable(length);
|
|
||||||
const start = this.offset;
|
|
||||||
this.offset += length;
|
|
||||||
return this.bytes.subarray(start, this.offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readUint8(): number {
|
|
||||||
this.ensureAvailable(1);
|
|
||||||
return this.view.getUint8(this.offset++);
|
|
||||||
}
|
|
||||||
|
|
||||||
private readInt8(): number {
|
|
||||||
this.ensureAvailable(1);
|
|
||||||
const value = this.view.getInt8(this.offset);
|
|
||||||
this.offset += 1;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readUint16(): number {
|
|
||||||
this.ensureAvailable(2);
|
|
||||||
const value = this.view.getUint16(this.offset);
|
|
||||||
this.offset += 2;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readInt16(): number {
|
|
||||||
this.ensureAvailable(2);
|
|
||||||
const value = this.view.getInt16(this.offset);
|
|
||||||
this.offset += 2;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readUint32(): number {
|
|
||||||
this.ensureAvailable(4);
|
|
||||||
const value = this.view.getUint32(this.offset);
|
|
||||||
this.offset += 4;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readInt32(): number {
|
|
||||||
this.ensureAvailable(4);
|
|
||||||
const value = this.view.getInt32(this.offset);
|
|
||||||
this.offset += 4;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readBigUint64(): bigint {
|
|
||||||
this.ensureAvailable(8);
|
|
||||||
const value = this.view.getBigUint64(this.offset);
|
|
||||||
this.offset += 8;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readBigInt64(): bigint {
|
|
||||||
this.ensureAvailable(8);
|
|
||||||
const value = this.view.getBigInt64(this.offset);
|
|
||||||
this.offset += 8;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readFloat32(): number {
|
|
||||||
this.ensureAvailable(4);
|
|
||||||
const value = this.view.getFloat32(this.offset);
|
|
||||||
this.offset += 4;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private readFloat64(): number {
|
|
||||||
this.ensureAvailable(8);
|
|
||||||
const value = this.view.getFloat64(this.offset);
|
|
||||||
this.offset += 8;
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureAvailable(length: number): void {
|
|
||||||
if (length < 0 || this.offset + length > this.bytes.byteLength) {
|
|
||||||
throw new Error("Unexpected end of MessagePack data");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/** 解码一个完整的 MessagePack 值。 */
|
private decodeArray(length: number): MessagePackValue[] {
|
||||||
decode(): MessagePackValue {
|
return Array.from({ length }, () => this.decode());
|
||||||
const prefix = this.readUint8();
|
}
|
||||||
|
|
||||||
if (prefix <= 0x7f) return prefix;
|
private decodeMap(length: number): { [key: string]: MessagePackValue } {
|
||||||
if (prefix >= 0xe0) return prefix - 0x100;
|
const result: { [key: string]: MessagePackValue } = {};
|
||||||
if ((prefix & 0xf0) === 0x80) return this.decodeMap(prefix & 0x0f);
|
|
||||||
if ((prefix & 0xf0) === 0x90) return this.decodeArray(prefix & 0x0f);
|
for (let index = 0; index < length; index += 1) {
|
||||||
if ((prefix & 0xe0) === 0xa0) return this.decodeString(prefix & 0x1f);
|
const key = this.decode();
|
||||||
|
if (typeof key !== "string" && typeof key !== "number" && typeof key !== "bigint") {
|
||||||
switch (prefix) {
|
throw new Error("Unsupported MessagePack map key");
|
||||||
case 0xc0:
|
}
|
||||||
return null;
|
result[String(key)] = this.decode();
|
||||||
case 0xc2:
|
}
|
||||||
return false;
|
|
||||||
case 0xc3:
|
return result;
|
||||||
return true;
|
}
|
||||||
case 0xc4:
|
|
||||||
return this.readBytes(this.readUint8());
|
private decodeString(length: number): string {
|
||||||
case 0xc5:
|
return this.textDecoder.decode(this.readBytes(length));
|
||||||
return this.readBytes(this.readUint16());
|
}
|
||||||
case 0xc6:
|
|
||||||
return this.readBytes(this.readUint32());
|
private readBytes(length: number): Uint8Array {
|
||||||
case 0xca:
|
this.ensureAvailable(length);
|
||||||
return this.readFloat32();
|
const start = this.offset;
|
||||||
case 0xcb:
|
this.offset += length;
|
||||||
return this.readFloat64();
|
return this.bytes.subarray(start, this.offset);
|
||||||
case 0xcc:
|
}
|
||||||
return this.readUint8();
|
|
||||||
case 0xcd:
|
private readUint8(): number {
|
||||||
return this.readUint16();
|
this.ensureAvailable(1);
|
||||||
case 0xce:
|
return this.view.getUint8(this.offset++);
|
||||||
return this.readUint32();
|
}
|
||||||
case 0xcf:
|
|
||||||
return this.readBigUint64();
|
private readInt8(): number {
|
||||||
case 0xd0:
|
this.ensureAvailable(1);
|
||||||
return this.readInt8();
|
const value = this.view.getInt8(this.offset);
|
||||||
case 0xd1:
|
this.offset += 1;
|
||||||
return this.readInt16();
|
return value;
|
||||||
case 0xd2:
|
}
|
||||||
return this.readInt32();
|
|
||||||
case 0xd3:
|
private readUint16(): number {
|
||||||
return this.readBigInt64();
|
this.ensureAvailable(2);
|
||||||
case 0xd9:
|
const value = this.view.getUint16(this.offset);
|
||||||
return this.decodeString(this.readUint8());
|
this.offset += 2;
|
||||||
case 0xda:
|
return value;
|
||||||
return this.decodeString(this.readUint16());
|
}
|
||||||
case 0xdb:
|
|
||||||
return this.decodeString(this.readUint32());
|
private readInt16(): number {
|
||||||
case 0xdc:
|
this.ensureAvailable(2);
|
||||||
return this.decodeArray(this.readUint16());
|
const value = this.view.getInt16(this.offset);
|
||||||
case 0xdd:
|
this.offset += 2;
|
||||||
return this.decodeArray(this.readUint32());
|
return value;
|
||||||
case 0xde:
|
}
|
||||||
return this.decodeMap(this.readUint16());
|
|
||||||
case 0xdf:
|
private readUint32(): number {
|
||||||
return this.decodeMap(this.readUint32());
|
this.ensureAvailable(4);
|
||||||
default:
|
const value = this.view.getUint32(this.offset);
|
||||||
throw new Error(`Unsupported MessagePack prefix 0x${prefix.toString(16)}`);
|
this.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readInt32(): number {
|
||||||
|
this.ensureAvailable(4);
|
||||||
|
const value = this.view.getInt32(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBigUint64(): bigint {
|
||||||
|
this.ensureAvailable(8);
|
||||||
|
const value = this.view.getBigUint64(this.offset);
|
||||||
|
this.offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readBigInt64(): bigint {
|
||||||
|
this.ensureAvailable(8);
|
||||||
|
const value = this.view.getBigInt64(this.offset);
|
||||||
|
this.offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFloat32(): number {
|
||||||
|
this.ensureAvailable(4);
|
||||||
|
const value = this.view.getFloat32(this.offset);
|
||||||
|
this.offset += 4;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private readFloat64(): number {
|
||||||
|
this.ensureAvailable(8);
|
||||||
|
const value = this.view.getFloat64(this.offset);
|
||||||
|
this.offset += 8;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureAvailable(length: number): void {
|
||||||
|
if (length < 0 || this.offset + length > this.bytes.byteLength) {
|
||||||
|
throw new Error("Unexpected end of MessagePack data");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 解码一个完整的 MessagePack 值。 */
|
||||||
|
decode(): MessagePackValue {
|
||||||
|
const prefix = this.readUint8();
|
||||||
|
|
||||||
|
if (prefix <= 0x7f) return prefix;
|
||||||
|
if (prefix >= 0xe0) return prefix - 0x100;
|
||||||
|
if ((prefix & 0xf0) === 0x80) return this.decodeMap(prefix & 0x0f);
|
||||||
|
if ((prefix & 0xf0) === 0x90) return this.decodeArray(prefix & 0x0f);
|
||||||
|
if ((prefix & 0xe0) === 0xa0) return this.decodeString(prefix & 0x1f);
|
||||||
|
|
||||||
|
switch (prefix) {
|
||||||
|
case 0xc0:
|
||||||
|
return null;
|
||||||
|
case 0xc2:
|
||||||
|
return false;
|
||||||
|
case 0xc3:
|
||||||
|
return true;
|
||||||
|
case 0xc4:
|
||||||
|
return this.readBytes(this.readUint8());
|
||||||
|
case 0xc5:
|
||||||
|
return this.readBytes(this.readUint16());
|
||||||
|
case 0xc6:
|
||||||
|
return this.readBytes(this.readUint32());
|
||||||
|
case 0xca:
|
||||||
|
return this.readFloat32();
|
||||||
|
case 0xcb:
|
||||||
|
return this.readFloat64();
|
||||||
|
case 0xcc:
|
||||||
|
return this.readUint8();
|
||||||
|
case 0xcd:
|
||||||
|
return this.readUint16();
|
||||||
|
case 0xce:
|
||||||
|
return this.readUint32();
|
||||||
|
case 0xcf:
|
||||||
|
return this.readBigUint64();
|
||||||
|
case 0xd0:
|
||||||
|
return this.readInt8();
|
||||||
|
case 0xd1:
|
||||||
|
return this.readInt16();
|
||||||
|
case 0xd2:
|
||||||
|
return this.readInt32();
|
||||||
|
case 0xd3:
|
||||||
|
return this.readBigInt64();
|
||||||
|
case 0xd9:
|
||||||
|
return this.decodeString(this.readUint8());
|
||||||
|
case 0xda:
|
||||||
|
return this.decodeString(this.readUint16());
|
||||||
|
case 0xdb:
|
||||||
|
return this.decodeString(this.readUint32());
|
||||||
|
case 0xdc:
|
||||||
|
return this.decodeArray(this.readUint16());
|
||||||
|
case 0xdd:
|
||||||
|
return this.decodeArray(this.readUint32());
|
||||||
|
case 0xde:
|
||||||
|
return this.decodeMap(this.readUint16());
|
||||||
|
case 0xdf:
|
||||||
|
return this.decodeMap(this.readUint32());
|
||||||
|
default:
|
||||||
|
throw new Error(`Unsupported MessagePack prefix 0x${prefix.toString(16)}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 解码 Base64 编码的 OneTalk 同步推送数据。 */
|
/** 解码 Base64 编码的 OneTalk 同步推送数据。 */
|
||||||
export function decodeOneTalkSyncPushData(encoded: string): MessagePackValue {
|
export function decodeOneTalkSyncPushData(encoded: string): MessagePackValue {
|
||||||
const binary = atob(encoded);
|
const binary = atob(encoded);
|
||||||
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0));
|
||||||
const decoder = new MessagePackDecoder(bytes);
|
const decoder = new MessagePackDecoder(bytes);
|
||||||
const value = decoder.decode();
|
const value = decoder.decode();
|
||||||
|
|
||||||
if (!decoder.done) throw new Error("Unexpected trailing MessagePack bytes");
|
if (!decoder.done) throw new Error("Unexpected trailing MessagePack bytes");
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 提供 main-page 内共享的无副作用辅助判断
|
// 提供 main-page 内共享的无副作用辅助判断
|
||||||
|
|
||||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,340 +9,342 @@ import { isAccountId } from "../src/onetalk/main-page/model.ts";
|
|||||||
|
|
||||||
const targetAccountId = 2500002169502;
|
const targetAccountId = 2500002169502;
|
||||||
const targetConversation = {
|
const targetConversation = {
|
||||||
cid: "conversation-1",
|
cid: "conversation-1",
|
||||||
accountId: targetAccountId,
|
accountId: targetAccountId,
|
||||||
accountIdEncrypt: "encrypted-contact-account",
|
accountIdEncrypt: "encrypted-contact-account",
|
||||||
aliId: "seller@icbu",
|
aliId: "seller@icbu",
|
||||||
aliIdEncrypt: "encrypted-seller-account",
|
aliIdEncrypt: "encrypted-seller-account",
|
||||||
};
|
};
|
||||||
|
|
||||||
function historyMessage(messageId, sendTime) {
|
function historyMessage(messageId, sendTime) {
|
||||||
return { messageId, sendTime };
|
return { messageId, sendTime };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPageWindow({ conversationPages, historyPages }) {
|
function createPageWindow({ conversationPages, historyPages }) {
|
||||||
const conversationCalls = [];
|
const conversationCalls = [];
|
||||||
const historyCalls = [];
|
const historyCalls = [];
|
||||||
let conversationIndex = 0;
|
let conversationIndex = 0;
|
||||||
let historyIndex = 0;
|
let historyIndex = 0;
|
||||||
let forbiddenCalls = 0;
|
let forbiddenCalls = 0;
|
||||||
|
|
||||||
const messageService = {
|
const messageService = {
|
||||||
async fetchMessagesWithoutUpdateToRead(options, conversation) {
|
async fetchMessagesWithoutUpdateToRead(options, conversation) {
|
||||||
historyCalls.push({ options, conversation });
|
historyCalls.push({ options, conversation });
|
||||||
return historyPages[historyIndex++];
|
return historyPages[historyIndex++];
|
||||||
},
|
|
||||||
fetchMessages() {
|
|
||||||
forbiddenCalls += 1;
|
|
||||||
},
|
|
||||||
updateMessageToRead() {
|
|
||||||
forbiddenCalls += 1;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const sdk = {
|
|
||||||
getConversationServiceV2() {
|
|
||||||
return {
|
|
||||||
async getConversationListByPagination(options) {
|
|
||||||
conversationCalls.push(options);
|
|
||||||
return conversationPages[conversationIndex++];
|
|
||||||
},
|
},
|
||||||
};
|
fetchMessages() {
|
||||||
},
|
forbiddenCalls += 1;
|
||||||
getMessageService() {
|
},
|
||||||
return messageService;
|
updateMessageToRead() {
|
||||||
},
|
forbiddenCalls += 1;
|
||||||
};
|
},
|
||||||
const pageWindow = {
|
};
|
||||||
location: {
|
const sdk = {
|
||||||
href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`,
|
getConversationServiceV2() {
|
||||||
},
|
return {
|
||||||
IcbuIM: {
|
async getConversationListByPagination(options) {
|
||||||
IMBaaSSDK: { default: sdk },
|
conversationCalls.push(options);
|
||||||
},
|
return conversationPages[conversationIndex++];
|
||||||
};
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
getMessageService() {
|
||||||
|
return messageService;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const pageWindow = {
|
||||||
|
location: {
|
||||||
|
href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`,
|
||||||
|
},
|
||||||
|
IcbuIM: {
|
||||||
|
IMBaaSSDK: { default: sdk },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pageWindow,
|
pageWindow,
|
||||||
conversationCalls,
|
conversationCalls,
|
||||||
historyCalls,
|
historyCalls,
|
||||||
messageService,
|
messageService,
|
||||||
forbiddenCalls: () => forbiddenCalls,
|
forbiddenCalls: () => forbiddenCalls,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test("accepts only finite non-empty OneTalk account identifiers", () => {
|
test("accepts only finite non-empty OneTalk account identifiers", () => {
|
||||||
for (const value of ["account", 0, -1, Number.MAX_SAFE_INTEGER]) {
|
for (const value of ["account", 0, -1, Number.MAX_SAFE_INTEGER]) {
|
||||||
assert.equal(isAccountId(value), true);
|
assert.equal(isAccountId(value), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const value of ["", Number.NaN, Number.POSITIVE_INFINITY, null, true]) {
|
for (const value of ["", Number.NaN, Number.POSITIVE_INFINITY, null, true]) {
|
||||||
assert.equal(isAccountId(value), false);
|
assert.equal(isAccountId(value), false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("scans the current conversation and exhausts history through the no-read API", async () => {
|
test("scans the current conversation and exhausts history through the no-read API", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [
|
conversationPages: [
|
||||||
{
|
{
|
||||||
list: [{ ...targetConversation, accountId: "other" }],
|
list: [{ ...targetConversation, accountId: "other" }],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
nextCursor: "page-2",
|
nextCursor: "page-2",
|
||||||
},
|
},
|
||||||
{ list: [targetConversation], hasMore: false },
|
{ list: [targetConversation], hasMore: false },
|
||||||
],
|
],
|
||||||
historyPages: [
|
historyPages: [
|
||||||
{
|
{
|
||||||
list: [historyMessage(4, 400), historyMessage(3, 300)],
|
list: [historyMessage(4, 400), historyMessage(3, 300)],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
list: [historyMessage(3, 300), historyMessage(2, 200)],
|
list: [historyMessage(3, 300), historyMessage(2, 200)],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
},
|
},
|
||||||
{ list: [historyMessage(1, 100)], hasMore: false },
|
{ list: [historyMessage(1, 100)], hasMore: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const sleeps = [];
|
const sleeps = [];
|
||||||
const randomValues = [0, 1];
|
const randomValues = [0, 1];
|
||||||
|
|
||||||
const result = await syncCurrentConversationHistory(fixture.pageWindow, {
|
const result = await syncCurrentConversationHistory(fixture.pageWindow, {
|
||||||
now: () => 500,
|
now: () => 500,
|
||||||
random: () => randomValues.shift(),
|
random: () => randomValues.shift(),
|
||||||
sleep: async (milliseconds) => sleeps.push(milliseconds),
|
sleep: async (milliseconds) => sleeps.push(milliseconds),
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { exit: "history_exhausted", pages: 3, uniqueMessages: 4 });
|
assert.deepEqual(result, { exit: "history_exhausted", pages: 3, uniqueMessages: 4 });
|
||||||
assert.deepEqual(fixture.conversationCalls, [
|
assert.deepEqual(fixture.conversationCalls, [
|
||||||
{ cursor: 0, count: 20 },
|
{ cursor: 0, count: 20 },
|
||||||
{ cursor: "page-2", count: 20 },
|
{ cursor: "page-2", count: 20 },
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
fixture.historyCalls.map(({ options }) => options),
|
fixture.historyCalls.map(({ options }) => options),
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
contactAccountId: targetAccountId,
|
contactAccountId: targetAccountId,
|
||||||
contactAccountIdEncrypt: "encrypted-contact-account",
|
contactAccountIdEncrypt: "encrypted-contact-account",
|
||||||
aliId: "seller@icbu",
|
aliId: "seller@icbu",
|
||||||
aliIdEncrypt: "encrypted-seller-account",
|
aliIdEncrypt: "encrypted-seller-account",
|
||||||
searchMessageId: "",
|
searchMessageId: "",
|
||||||
timeSlide: { forward: false, timeStamp: 500, pageSize: 20 },
|
timeSlide: { forward: false, timeStamp: 500, pageSize: 20 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
contactAccountId: targetAccountId,
|
contactAccountId: targetAccountId,
|
||||||
contactAccountIdEncrypt: "encrypted-contact-account",
|
contactAccountIdEncrypt: "encrypted-contact-account",
|
||||||
aliId: "seller@icbu",
|
aliId: "seller@icbu",
|
||||||
aliIdEncrypt: "encrypted-seller-account",
|
aliIdEncrypt: "encrypted-seller-account",
|
||||||
searchMessageId: "",
|
searchMessageId: "",
|
||||||
timeSlide: { forward: false, timeStamp: 300, pageSize: 20 },
|
timeSlide: { forward: false, timeStamp: 300, pageSize: 20 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
contactAccountId: targetAccountId,
|
contactAccountId: targetAccountId,
|
||||||
contactAccountIdEncrypt: "encrypted-contact-account",
|
contactAccountIdEncrypt: "encrypted-contact-account",
|
||||||
aliId: "seller@icbu",
|
aliId: "seller@icbu",
|
||||||
aliIdEncrypt: "encrypted-seller-account",
|
aliIdEncrypt: "encrypted-seller-account",
|
||||||
searchMessageId: "",
|
searchMessageId: "",
|
||||||
timeSlide: { forward: false, timeStamp: 200, pageSize: 20 },
|
timeSlide: { forward: false, timeStamp: 200, pageSize: 20 },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.equal(fixture.historyCalls[0].conversation, targetConversation);
|
assert.equal(fixture.historyCalls[0].conversation, targetConversation);
|
||||||
assert.deepEqual(sleeps, [1_000, 3_000]);
|
assert.deepEqual(sleeps, [1_000, 3_000]);
|
||||||
assert.equal(fixture.forbiddenCalls(), 0);
|
assert.equal(fixture.forbiddenCalls(), 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the installed global entry has no dependency override and returns completion", async () => {
|
test("the installed global entry has no dependency override and returns completion", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
||||||
historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }],
|
historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }],
|
||||||
});
|
});
|
||||||
|
|
||||||
installCurrentConversationHistorySync(fixture.pageWindow);
|
installCurrentConversationHistorySync(fixture.pageWindow);
|
||||||
const entry = fixture.pageWindow.__tradeMessageCenterOneTalk.syncCurrentConversationHistory;
|
const entry = fixture.pageWindow.__tradeMessageCenterOneTalk.syncCurrentConversationHistory;
|
||||||
|
|
||||||
assert.equal(entry.length, 0);
|
assert.equal(entry.length, 0);
|
||||||
assert.deepEqual(await entry(), { exit: "history_exhausted", pages: 1, uniqueMessages: 1 });
|
assert.deepEqual(await entry(), { exit: "history_exhausted", pages: 1, uniqueMessages: 1 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test("skips unrelated conversation shapes and validates only the active account match", async () => {
|
test("skips unrelated conversation shapes and validates only the active account match", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [
|
conversationPages: [
|
||||||
{
|
{
|
||||||
list: [
|
list: [
|
||||||
{ cid: "group-conversation" },
|
{ cid: "group-conversation" },
|
||||||
{ accountId: "system-conversation" },
|
{ accountId: "system-conversation" },
|
||||||
targetConversation,
|
targetConversation,
|
||||||
|
],
|
||||||
|
hasMore: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
hasMore: false,
|
historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }],
|
||||||
},
|
});
|
||||||
],
|
|
||||||
historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }],
|
|
||||||
});
|
|
||||||
|
|
||||||
assert.deepEqual(await syncCurrentConversationHistory(fixture.pageWindow, { now: () => 500 }), {
|
assert.deepEqual(await syncCurrentConversationHistory(fixture.pageWindow, { now: () => 500 }), {
|
||||||
exit: "history_exhausted",
|
exit: "history_exhausted",
|
||||||
pages: 1,
|
pages: 1,
|
||||||
uniqueMessages: 1,
|
uniqueMessages: 1,
|
||||||
});
|
});
|
||||||
assert.equal(fixture.historyCalls[0].conversation, targetConversation);
|
assert.equal(fixture.historyCalls[0].conversation, targetConversation);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("converts SDK request failures to a stable secret-free error", async () => {
|
test("converts SDK request failures to a stable secret-free error", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
||||||
historyPages: [],
|
historyPages: [],
|
||||||
});
|
});
|
||||||
fixture.messageService.fetchMessagesWithoutUpdateToRead = async () => {
|
fixture.messageService.fetchMessagesWithoutUpdateToRead = async () => {
|
||||||
throw new Error("chatToken=PAGE-SECRET&contactAccountIdEncrypt=BUYER-SECRET");
|
throw new Error("chatToken=PAGE-SECRET&contactAccountIdEncrypt=BUYER-SECRET");
|
||||||
};
|
};
|
||||||
|
|
||||||
const error = await syncCurrentConversationHistory(fixture.pageWindow).catch((reason) => reason);
|
const error = await syncCurrentConversationHistory(fixture.pageWindow).catch(
|
||||||
assert.equal(error.message, "onetalk_history_message_request_failed");
|
(reason) => reason,
|
||||||
assert.equal(error.message.includes("SECRET"), false);
|
);
|
||||||
|
assert.equal(error.message, "onetalk_history_message_request_failed");
|
||||||
|
assert.equal(error.message.includes("SECRET"), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reuses the exact in-flight promise for the same page", async () => {
|
test("reuses the exact in-flight promise for the same page", async () => {
|
||||||
let resolveHistory;
|
let resolveHistory;
|
||||||
const historyResponse = new Promise((resolve) => {
|
const historyResponse = new Promise((resolve) => {
|
||||||
resolveHistory = resolve;
|
resolveHistory = resolve;
|
||||||
});
|
});
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
||||||
historyPages: [historyResponse],
|
historyPages: [historyResponse],
|
||||||
});
|
});
|
||||||
const dependencies = { now: () => 500, sleep: async () => {}, random: () => 0 };
|
const dependencies = { now: () => 500, sleep: async () => {}, random: () => 0 };
|
||||||
|
|
||||||
const first = syncCurrentConversationHistory(fixture.pageWindow, dependencies);
|
const first = syncCurrentConversationHistory(fixture.pageWindow, dependencies);
|
||||||
const second = syncCurrentConversationHistory(fixture.pageWindow, dependencies);
|
const second = syncCurrentConversationHistory(fixture.pageWindow, dependencies);
|
||||||
assert.equal(first, second);
|
assert.equal(first, second);
|
||||||
|
|
||||||
resolveHistory({ list: [historyMessage("m1", 100)], hasMore: false });
|
resolveHistory({ list: [historyMessage("m1", 100)], hasMore: false });
|
||||||
await first;
|
await first;
|
||||||
assert.equal(fixture.historyCalls.length, 1);
|
assert.equal(fixture.historyCalls.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails closed when conversation pagination does not advance", async () => {
|
test("fails closed when conversation pagination does not advance", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [
|
conversationPages: [
|
||||||
{
|
{
|
||||||
list: [{ ...targetConversation, accountId: "other" }],
|
list: [{ ...targetConversation, accountId: "other" }],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
nextCursor: "same",
|
nextCursor: "same",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
list: [{ ...targetConversation, accountId: "other" }],
|
list: [{ ...targetConversation, accountId: "other" }],
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
nextCursor: "same",
|
nextCursor: "same",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
historyPages: [],
|
historyPages: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
syncCurrentConversationHistory(fixture.pageWindow),
|
syncCurrentConversationHistory(fixture.pageWindow),
|
||||||
/onetalk_history_conversation_cursor_stalled/,
|
/onetalk_history_conversation_cursor_stalled/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails closed when history pages cannot make safe progress", async (t) => {
|
test("fails closed when history pages cannot make safe progress", async (t) => {
|
||||||
const cases = [
|
const cases = [
|
||||||
{ name: "empty page", page: { list: [], hasMore: false }, error: "invalid_message_page" },
|
{ name: "empty page", page: { list: [], hasMore: false }, error: "invalid_message_page" },
|
||||||
{
|
{
|
||||||
name: "missing finite sendTime",
|
name: "missing finite sendTime",
|
||||||
page: { list: [{ messageId: 1 }], hasMore: false },
|
page: { list: [{ messageId: 1 }], hasMore: false },
|
||||||
error: "invalid_message_page",
|
error: "invalid_message_page",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "time cursor stalls",
|
name: "time cursor stalls",
|
||||||
page: { list: [historyMessage("m1", 500)], hasMore: false },
|
page: { list: [historyMessage("m1", 500)], hasMore: false },
|
||||||
error: "message_cursor_stalled",
|
error: "message_cursor_stalled",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const scenario of cases) {
|
for (const scenario of cases) {
|
||||||
await t.test(scenario.name, async () => {
|
await t.test(scenario.name, async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
||||||
historyPages: [scenario.page],
|
historyPages: [scenario.page],
|
||||||
});
|
});
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
syncCurrentConversationHistory(fixture.pageWindow, { now: () => 500 }),
|
syncCurrentConversationHistory(fixture.pageWindow, { now: () => 500 }),
|
||||||
new RegExp(`onetalk_history_${scenario.error}`),
|
new RegExp(`onetalk_history_${scenario.error}`),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails closed when a page contains no new message IDs", async () => {
|
test("fails closed when a page contains no new message IDs", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
||||||
historyPages: [
|
historyPages: [
|
||||||
{ list: [historyMessage(1, 300)], hasMore: true },
|
{ list: [historyMessage(1, 300)], hasMore: true },
|
||||||
{ list: [historyMessage("1", 200)], hasMore: false },
|
{ list: [historyMessage("1", 200)], hasMore: false },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
syncCurrentConversationHistory(fixture.pageWindow, {
|
syncCurrentConversationHistory(fixture.pageWindow, {
|
||||||
now: () => 500,
|
now: () => 500,
|
||||||
random: () => 0,
|
random: () => 0,
|
||||||
sleep: async () => {},
|
sleep: async () => {},
|
||||||
}),
|
}),
|
||||||
/onetalk_history_message_cursor_stalled/,
|
/onetalk_history_message_cursor_stalled/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("stops when the active account changes during a history request", async () => {
|
test("stops when the active account changes during a history request", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
conversationPages: [{ list: [targetConversation], hasMore: false }],
|
||||||
historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }],
|
historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }],
|
||||||
});
|
});
|
||||||
const originalFetch = fixture.messageService.fetchMessagesWithoutUpdateToRead;
|
const originalFetch = fixture.messageService.fetchMessagesWithoutUpdateToRead;
|
||||||
fixture.messageService.fetchMessagesWithoutUpdateToRead = async (...args) => {
|
fixture.messageService.fetchMessagesWithoutUpdateToRead = async (...args) => {
|
||||||
const response = await originalFetch(...args);
|
const response = await originalFetch(...args);
|
||||||
fixture.pageWindow.location.href =
|
fixture.pageWindow.location.href =
|
||||||
"https://onetalk.alibaba.com/?activeAccountId=different-account";
|
"https://onetalk.alibaba.com/?activeAccountId=different-account";
|
||||||
return response;
|
return response;
|
||||||
};
|
};
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
syncCurrentConversationHistory(fixture.pageWindow),
|
syncCurrentConversationHistory(fixture.pageWindow),
|
||||||
/onetalk_history_active_account_changed/,
|
/onetalk_history_active_account_changed/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails closed when the required page SDK is unavailable", async () => {
|
test("fails closed when the required page SDK is unavailable", async () => {
|
||||||
let topLevelGetterCalls = 0;
|
let topLevelGetterCalls = 0;
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
syncCurrentConversationHistory({
|
syncCurrentConversationHistory({
|
||||||
location: {
|
location: {
|
||||||
href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`,
|
href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`,
|
||||||
},
|
},
|
||||||
getConversationServiceV2() {
|
getConversationServiceV2() {
|
||||||
topLevelGetterCalls += 1;
|
topLevelGetterCalls += 1;
|
||||||
},
|
},
|
||||||
getMessageService() {
|
getMessageService() {
|
||||||
topLevelGetterCalls += 1;
|
topLevelGetterCalls += 1;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
/onetalk_history_sdk_unavailable/,
|
/onetalk_history_sdk_unavailable/,
|
||||||
);
|
);
|
||||||
assert.equal(topLevelGetterCalls, 0);
|
assert.equal(topLevelGetterCalls, 0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fails closed when the matched conversation lacks required identity fields", async () => {
|
test("fails closed when the matched conversation lacks required identity fields", async () => {
|
||||||
const fixture = createPageWindow({
|
const fixture = createPageWindow({
|
||||||
conversationPages: [
|
conversationPages: [
|
||||||
{
|
{
|
||||||
list: [{ ...targetConversation, cid: undefined }],
|
list: [{ ...targetConversation, cid: undefined }],
|
||||||
hasMore: false,
|
hasMore: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
historyPages: [],
|
historyPages: [],
|
||||||
});
|
});
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
syncCurrentConversationHistory(fixture.pageWindow),
|
syncCurrentConversationHistory(fixture.pageWindow),
|
||||||
/onetalk_history_invalid_conversation_page/,
|
/onetalk_history_invalid_conversation_page/,
|
||||||
);
|
);
|
||||||
assert.equal(fixture.historyCalls.length, 0);
|
assert.equal(fixture.historyCalls.length, 0);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,31 +6,31 @@ import test from "node:test";
|
|||||||
import { decodeOneTalkSyncPushData } from "../src/onetalk/main-page/sync-push-decoder.ts";
|
import { decodeOneTalkSyncPushData } from "../src/onetalk/main-page/sync-push-decoder.ts";
|
||||||
|
|
||||||
test("decodes OneTalk status payloads", () => {
|
test("decodes OneTalk status payloads", () => {
|
||||||
const decoded = decodeOneTalkSyncPushData(
|
const decoded = decodeOneTalkSyncPushData(
|
||||||
"gQGRhAHaACYyMjA4MzE0MDAwNzk4LTI1MDAwMDIxNjk1MDIjMTEwMTFAaWNidQIBAwEEsjI1MDAwMDIxNjk1MDJAaWNidQ==",
|
"gQGRhAHaACYyMjA4MzE0MDAwNzk4LTI1MDAwMDIxNjk1MDIjMTEwMTFAaWNidQIBAwEEsjI1MDAwMDIxNjk1MDJAaWNidQ==",
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.deepEqual(decoded, {
|
assert.deepEqual(decoded, {
|
||||||
1: [
|
1: [
|
||||||
{
|
{
|
||||||
1: "2208314000798-2500002169502#11011@icbu",
|
1: "2208314000798-2500002169502#11011@icbu",
|
||||||
2: 1,
|
2: 1,
|
||||||
3: 1,
|
3: 1,
|
||||||
4: "2500002169502@icbu",
|
4: "2500002169502@icbu",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("decodes OneTalk message payloads", () => {
|
test("decodes OneTalk message payloads", () => {
|
||||||
const decoded = decodeOneTalkSyncPushData("ggGs5om+5L2g55yf6Zq+As8AAAGgODt1FA==");
|
const decoded = decodeOneTalkSyncPushData("ggGs5om+5L2g55yf6Zq+As8AAAGgODt1FA==");
|
||||||
|
|
||||||
assert.deepEqual(decoded, {
|
assert.deepEqual(decoded, {
|
||||||
1: "找你真难",
|
1: "找你真难",
|
||||||
2: 1787649815828n,
|
2: 1787649815828n,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects truncated payloads", () => {
|
test("rejects truncated payloads", () => {
|
||||||
assert.throws(() => decodeOneTalkSyncPushData("2gA="), /Unexpected end/);
|
assert.throws(() => decodeOneTalkSyncPushData("2gA="), /Unexpected end/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,188 +6,188 @@ import test from "node:test";
|
|||||||
import { installOneTalkMessageObserver } from "../src/onetalk/main-page/message-observer/entry.ts";
|
import { installOneTalkMessageObserver } from "../src/onetalk/main-page/message-observer/entry.ts";
|
||||||
|
|
||||||
class FakeWebSocket extends EventTarget {
|
class FakeWebSocket extends EventTarget {
|
||||||
static CONNECTING = 0;
|
static CONNECTING = 0;
|
||||||
static OPEN = 1;
|
static OPEN = 1;
|
||||||
static CLOSING = 2;
|
static CLOSING = 2;
|
||||||
static CLOSED = 3;
|
static CLOSED = 3;
|
||||||
|
|
||||||
constructor(url, protocols) {
|
constructor(url, protocols) {
|
||||||
super();
|
super();
|
||||||
this.url = String(url);
|
this.url = String(url);
|
||||||
this.protocols = protocols;
|
this.protocols = protocols;
|
||||||
}
|
}
|
||||||
|
|
||||||
receive(data) {
|
receive(data) {
|
||||||
this.dispatchEvent(new MessageEvent("message", { data }));
|
this.dispatchEvent(new MessageEvent("message", { data }));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function testPageWindow() {
|
function testPageWindow() {
|
||||||
const logs = [];
|
const logs = [];
|
||||||
return {
|
return {
|
||||||
logs,
|
logs,
|
||||||
pageWindow: {
|
pageWindow: {
|
||||||
WebSocket: FakeWebSocket,
|
WebSocket: FakeWebSocket,
|
||||||
console: {
|
console: {
|
||||||
log(...args) {
|
log(...args) {
|
||||||
logs.push(args);
|
logs.push(args);
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
};
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
test("prints raw messages from the OneTalk WebSocket", () => {
|
test("prints raw messages from the OneTalk WebSocket", () => {
|
||||||
const { logs, pageWindow } = testPageWindow();
|
const { logs, pageWindow } = testPageWindow();
|
||||||
|
|
||||||
installOneTalkMessageObserver(pageWindow);
|
installOneTalkMessageObserver(pageWindow);
|
||||||
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
||||||
const payload = { raw: "unchanged" };
|
const payload = { raw: "unchanged" };
|
||||||
socket.receive(payload);
|
socket.receive(payload);
|
||||||
|
|
||||||
assert.ok(socket instanceof FakeWebSocket);
|
assert.ok(socket instanceof FakeWebSocket);
|
||||||
assert.equal(pageWindow.WebSocket.OPEN, FakeWebSocket.OPEN);
|
assert.equal(pageWindow.WebSocket.OPEN, FakeWebSocket.OPEN);
|
||||||
assert.equal(logs.length, 1);
|
assert.equal(logs.length, 1);
|
||||||
assert.equal(logs[0][1], payload);
|
assert.equal(logs[0][1], payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prints plaintext messages from conversation response frames", () => {
|
test("prints plaintext messages from conversation response frames", () => {
|
||||||
const { logs, pageWindow } = testPageWindow();
|
const { logs, pageWindow } = testPageWindow();
|
||||||
|
|
||||||
installOneTalkMessageObserver(pageWindow);
|
installOneTalkMessageObserver(pageWindow);
|
||||||
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
||||||
const message = {
|
const message = {
|
||||||
messageId: "4264696193791.PNM",
|
messageId: "4264696193791.PNM",
|
||||||
cid: "2208314000798-2500002169502#11011@icbu",
|
cid: "2208314000798-2500002169502#11011@icbu",
|
||||||
createAt: 1787649815828,
|
createAt: 1787649815828,
|
||||||
content: { text: { content: "找你真难" }, contentType: 1 },
|
content: { text: { content: "找你真难" }, contentType: 1 },
|
||||||
sender: { uid: "2208314000798@icbu" },
|
sender: { uid: "2208314000798@icbu" },
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
};
|
};
|
||||||
const frame = JSON.stringify({
|
const frame = JSON.stringify({
|
||||||
code: 200,
|
code: 200,
|
||||||
body: [
|
body: [
|
||||||
{
|
{
|
||||||
singleChatUserConversation: {
|
singleChatUserConversation: {
|
||||||
lastMessage: { message, readStatus: 2, msgStatus: 1 },
|
lastMessage: { message, readStatus: 2, msgStatus: 1 },
|
||||||
singleChatConversation: {
|
singleChatConversation: {
|
||||||
pairFirst: "2208314000798@icbu",
|
pairFirst: "2208314000798@icbu",
|
||||||
pairSecond: "2500002169502@icbu",
|
pairSecond: "2500002169502@icbu",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
socket.receive(frame);
|
socket.receive(frame);
|
||||||
|
|
||||||
assert.equal(logs.length, 2);
|
assert.equal(logs.length, 2);
|
||||||
assert.equal(logs[1][0], "[Trade Message Center][OneTalk new message]");
|
assert.equal(logs[1][0], "[Trade Message Center][OneTalk new message]");
|
||||||
assert.equal(logs[0][1], frame);
|
assert.equal(logs[0][1], frame);
|
||||||
assert.deepEqual(logs[1][1], {
|
assert.deepEqual(logs[1][1], {
|
||||||
messageType: "new",
|
messageType: "new",
|
||||||
conversationId: "2208314000798-2500002169502#11011@icbu",
|
conversationId: "2208314000798-2500002169502#11011@icbu",
|
||||||
messageId: "4264696193791.PNM",
|
messageId: "4264696193791.PNM",
|
||||||
sentAt: 1787649815828,
|
sentAt: 1787649815828,
|
||||||
contentType: 1,
|
contentType: 1,
|
||||||
text: "找你真难",
|
text: "找你真难",
|
||||||
senderId: "2208314000798@icbu",
|
senderId: "2208314000798@icbu",
|
||||||
participantIds: ["2208314000798@icbu", "2500002169502@icbu"],
|
participantIds: ["2208314000798@icbu", "2500002169502@icbu"],
|
||||||
direction: "received",
|
direction: "received",
|
||||||
readStatus: 2,
|
readStatus: 2,
|
||||||
messageStatus: 1,
|
messageStatus: 1,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("prints messages from history response frames", () => {
|
test("prints messages from history response frames", () => {
|
||||||
const { logs, pageWindow } = testPageWindow();
|
const { logs, pageWindow } = testPageWindow();
|
||||||
|
|
||||||
installOneTalkMessageObserver(pageWindow);
|
installOneTalkMessageObserver(pageWindow);
|
||||||
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
||||||
const frame = JSON.stringify({
|
const frame = JSON.stringify({
|
||||||
code: 200,
|
code: 200,
|
||||||
body: {
|
body: {
|
||||||
nextCursor: 1787157210197,
|
nextCursor: 1787157210197,
|
||||||
hasMore: 1,
|
hasMore: 1,
|
||||||
userMessageModels: [
|
userMessageModels: [
|
||||||
{
|
{
|
||||||
readStatus: 2,
|
readStatus: 2,
|
||||||
msgStatus: 1,
|
msgStatus: 1,
|
||||||
message: {
|
message: {
|
||||||
messageId: "4268023992386.PNM",
|
messageId: "4268023992386.PNM",
|
||||||
cid: "2208314000798-2500002169502#11011@icbu",
|
cid: "2208314000798-2500002169502#11011@icbu",
|
||||||
createAt: 1787212003008,
|
createAt: 1787212003008,
|
||||||
content: { text: { content: "我需要深色的" }, contentType: 1 },
|
content: { text: { content: "我需要深色的" }, contentType: 1 },
|
||||||
sender: { uid: "2500002169502@icbu" },
|
sender: { uid: "2500002169502@icbu" },
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
readStatus: 2,
|
||||||
|
msgStatus: 1,
|
||||||
|
message: {
|
||||||
|
messageId: "4260308763261.PNM",
|
||||||
|
cid: "2208314000798-2500002169502#11011@icbu",
|
||||||
|
createAt: 1787210475840,
|
||||||
|
content: { custom: { type: 10010 }, contentType: 101 },
|
||||||
|
sender: { uid: "2500002169502@icbu" },
|
||||||
|
unreadCount: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
});
|
||||||
readStatus: 2,
|
socket.receive(frame);
|
||||||
msgStatus: 1,
|
|
||||||
message: {
|
|
||||||
messageId: "4260308763261.PNM",
|
|
||||||
cid: "2208314000798-2500002169502#11011@icbu",
|
|
||||||
createAt: 1787210475840,
|
|
||||||
content: { custom: { type: 10010 }, contentType: 101 },
|
|
||||||
sender: { uid: "2500002169502@icbu" },
|
|
||||||
unreadCount: 0,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
socket.receive(frame);
|
|
||||||
|
|
||||||
assert.equal(logs.length, 3);
|
assert.equal(logs.length, 3);
|
||||||
assert.equal(logs[1][0], "[Trade Message Center][OneTalk history message]");
|
assert.equal(logs[1][0], "[Trade Message Center][OneTalk history message]");
|
||||||
assert.deepEqual(logs[1][1], {
|
assert.deepEqual(logs[1][1], {
|
||||||
messageType: "history",
|
messageType: "history",
|
||||||
conversationId: "2208314000798-2500002169502#11011@icbu",
|
conversationId: "2208314000798-2500002169502#11011@icbu",
|
||||||
messageId: "4268023992386.PNM",
|
messageId: "4268023992386.PNM",
|
||||||
sentAt: 1787212003008,
|
sentAt: 1787212003008,
|
||||||
contentType: 1,
|
contentType: 1,
|
||||||
text: "我需要深色的",
|
text: "我需要深色的",
|
||||||
senderId: "2500002169502@icbu",
|
senderId: "2500002169502@icbu",
|
||||||
participantIds: ["2208314000798@icbu", "2500002169502@icbu"],
|
participantIds: ["2208314000798@icbu", "2500002169502@icbu"],
|
||||||
direction: "sent",
|
direction: "sent",
|
||||||
readStatus: 2,
|
readStatus: 2,
|
||||||
messageStatus: 1,
|
messageStatus: 1,
|
||||||
unreadCount: 0,
|
unreadCount: 0,
|
||||||
});
|
});
|
||||||
assert.equal(logs[2][0], "[Trade Message Center][OneTalk history message]");
|
assert.equal(logs[2][0], "[Trade Message Center][OneTalk history message]");
|
||||||
assert.equal(logs[2][1].contentType, 101);
|
assert.equal(logs[2][1].contentType, 101);
|
||||||
assert.equal(logs[2][1].text, null);
|
assert.equal(logs[2][1].text, null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not treat sync push packages as plaintext messages", () => {
|
test("does not treat sync push packages as plaintext messages", () => {
|
||||||
const { logs, pageWindow } = testPageWindow();
|
const { logs, pageWindow } = testPageWindow();
|
||||||
|
|
||||||
installOneTalkMessageObserver(pageWindow);
|
installOneTalkMessageObserver(pageWindow);
|
||||||
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
|
||||||
socket.receive(
|
socket.receive(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
lwp: "/s/sync",
|
lwp: "/s/sync",
|
||||||
body: { syncPushPackage: { data: [{ data: "encoded" }] } },
|
body: { syncPushPackage: { data: [{ data: "encoded" }] } },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
assert.equal(logs.length, 1);
|
assert.equal(logs.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("ignores other hosts and installs only once", () => {
|
test("ignores other hosts and installs only once", () => {
|
||||||
const { logs, pageWindow } = testPageWindow();
|
const { logs, pageWindow } = testPageWindow();
|
||||||
|
|
||||||
installOneTalkMessageObserver(pageWindow);
|
installOneTalkMessageObserver(pageWindow);
|
||||||
const installedConstructor = pageWindow.WebSocket;
|
const installedConstructor = pageWindow.WebSocket;
|
||||||
installOneTalkMessageObserver(pageWindow);
|
installOneTalkMessageObserver(pageWindow);
|
||||||
|
|
||||||
assert.equal(pageWindow.WebSocket, installedConstructor);
|
assert.equal(pageWindow.WebSocket, installedConstructor);
|
||||||
|
|
||||||
const unrelated = new pageWindow.WebSocket("wss://example.com/");
|
const unrelated = new pageWindow.WebSocket("wss://example.com/");
|
||||||
const lookalike = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com.example.com/");
|
const lookalike = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com.example.com/");
|
||||||
unrelated.receive("unrelated");
|
unrelated.receive("unrelated");
|
||||||
lookalike.receive("lookalike");
|
lookalike.receive("lookalike");
|
||||||
|
|
||||||
assert.deepEqual(logs, []);
|
assert.deepEqual(logs, []);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"extends": "../../tsconfig.base.json",
|
"extends": "../../tsconfig.base.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"moduleResolution": "Bundler",
|
"moduleResolution": "Bundler",
|
||||||
"allowImportingTsExtensions": true,
|
"allowImportingTsExtensions": true,
|
||||||
"lib": ["ES2022", "DOM"],
|
"lib": ["ES2022", "DOM"],
|
||||||
"types": [],
|
"types": [],
|
||||||
"noEmit": true
|
"noEmit": true
|
||||||
},
|
},
|
||||||
"include": ["src", "vite.config.ts", "popup"]
|
"include": ["src", "vite.config.ts", "popup"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,33 +9,34 @@ const configDir = fileURLToPath(new URL(".", import.meta.url));
|
|||||||
const buildHash = process.env.BUILD_HASH || randomBytes(8).toString("hex");
|
const buildHash = process.env.BUILD_HASH || randomBytes(8).toString("hex");
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
root: configDir,
|
root: configDir,
|
||||||
publicDir: resolve(configDir, "public"),
|
publicDir: resolve(configDir, "public"),
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
name: "build-hash",
|
name: "build-hash",
|
||||||
closeBundle() {
|
closeBundle() {
|
||||||
console.info(`Build hash: ${buildHash}`);
|
console.info(`Build hash: ${buildHash}`);
|
||||||
},
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
build: {
|
||||||
|
outDir: resolve(configDir, "dist"),
|
||||||
|
emptyOutDir: true,
|
||||||
|
rollupOptions: {
|
||||||
|
input: {
|
||||||
|
"popup/popup": resolve(configDir, "popup/popup.html"),
|
||||||
|
"onetalk/main-page/page-script": resolve(
|
||||||
|
configDir,
|
||||||
|
"src/onetalk/main-page/page-script-entry.ts",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
output: {
|
||||||
|
banner: (chunk) =>
|
||||||
|
chunk.isEntry ? `console.info("Build hash: ${buildHash}");` : "",
|
||||||
|
entryFileNames: "[name].js",
|
||||||
|
chunkFileNames: "chunks/[name]-[hash].js",
|
||||||
|
assetFileNames: "assets/[name]-[hash][extname]",
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
|
||||||
build: {
|
|
||||||
outDir: resolve(configDir, "dist"),
|
|
||||||
emptyOutDir: true,
|
|
||||||
rollupOptions: {
|
|
||||||
input: {
|
|
||||||
"popup/popup": resolve(configDir, "popup/popup.html"),
|
|
||||||
"onetalk/main-page/page-script": resolve(
|
|
||||||
configDir,
|
|
||||||
"src/onetalk/main-page/page-script-entry.ts",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
output: {
|
|
||||||
banner: (chunk) => (chunk.isEntry ? `console.info("Build hash: ${buildHash}");` : ""),
|
|
||||||
entryFileNames: "[name].js",
|
|
||||||
chunkFileNames: "chunks/[name]-[hash].js",
|
|
||||||
assetFileNames: "assets/[name]-[hash][extname]",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "@trade-message-center/server",
|
"name": "@trade-message-center/server",
|
||||||
"private": true,
|
"version": "0.1.0",
|
||||||
"version": "0.1.0",
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"fastify": "^5.12.1"
|
"fastify": "^5.12.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+29
-29
@@ -1,31 +1,31 @@
|
|||||||
{
|
{
|
||||||
"name": "trade-message-center",
|
"name": "trade-message-center",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
"apps/*"
|
"apps/*"
|
||||||
],
|
],
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "pnpm --parallel --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run dev",
|
"dev": "pnpm --parallel --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run dev",
|
||||||
"build": "pnpm --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run build",
|
"build": "pnpm --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run build",
|
||||||
"typecheck": "pnpm --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run typecheck",
|
"typecheck": "pnpm --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run typecheck",
|
||||||
"test": "pnpm --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run test",
|
"test": "pnpm --filter @trade-message-center/server --filter @trade-message-center/chrome-extension --if-present run test",
|
||||||
"format": "oxfmt",
|
"format": "oxfmt",
|
||||||
"format:check": "oxfmt --check",
|
"format:check": "oxfmt --check",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"husky": "^9.1.7",
|
"husky": "^9.1.7",
|
||||||
"lint-staged": "^17.3.0",
|
"lint-staged": "^17.3.0",
|
||||||
"oxfmt": "^0.64.0",
|
"oxfmt": "^0.64.0",
|
||||||
"typescript": "^5.7.3"
|
"typescript": "^5.7.3"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*": "oxfmt --no-error-on-unmatched-pattern"
|
"*": "oxfmt --no-error-on-unmatched-pattern"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.22.2 <23"
|
"node": ">=22.22.2 <23"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.7.0"
|
"packageManager": "pnpm@11.7.0"
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
packages:
|
packages:
|
||||||
- apps/*
|
- apps/*
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|||||||
+10
-10
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2022",
|
"target": "ES2022",
|
||||||
"module": "NodeNext",
|
"module": "NodeNext",
|
||||||
"moduleResolution": "NodeNext",
|
"moduleResolution": "NodeNext",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"resolveJsonModule": true,
|
"resolveJsonModule": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user