chore: standardize repository formatting and editor setup

This commit is contained in:
YBF
2026-08-26 03:31:51 +08:00
parent 7a202de709
commit c979912da7
36 changed files with 1347 additions and 1230 deletions
+11
View File
@@ -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
View File
@@ -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 @@
- 仓库统一使用 Oxfmtlint-staged 只格式化暂存文件,Husky 在 `pre-commit` 阶段触发该检查。 - 仓库统一使用 Oxfmtlint-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 个空格缩进?
+36
View File
@@ -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 个字符的职责注释,入口文件是否描述目录职责?
- 主函数是否有独立职责注释并位于所有依赖声明之后? - 主函数是否有独立职责注释并位于所有依赖声明之后?
- 入口函数是否是最后一个函数声明,其后是否只有启动调用或显式导出? - 入口函数是否是最后一个函数声明,其后是否只有启动调用或显式导出?
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["oxc.oxc-vscode"]
}
+29
View File
@@ -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
}
}
+22
View File
@@ -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。
+15 -15
View File
@@ -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"
} }
} }
+33 -33
View File
@@ -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>
+13 -13
View File
@@ -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();
+26 -26
View File
@@ -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"
}
} }
+1 -1
View File
@@ -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, []);
}); });
+10 -10
View File
@@ -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"]
} }
+29 -28
View File
@@ -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]",
},
},
},
}); });
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -1,4 +1,4 @@
packages: packages:
- apps/* - apps/*
allowBuilds: allowBuilds:
esbuild: true esbuild: true
+10 -10
View File
@@ -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
} }
} }