diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..29ff089 --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index aadcc03..f17de6d 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -1,4 +1,13 @@ { - "$schema": "./node_modules/oxfmt/configuration_schema.json", - "ignorePatterns": [".agents/**", ".codex/**", ".trellis/**", "AGENTS.md", "pnpm-lock.yaml"] + "$schema": "./node_modules/oxfmt/configuration_schema.json", + "tabWidth": 4, + "useTabs": false, + "ignorePatterns": [ + ".agents/**", + ".codex/**", + ".trellis/**", + "AGENTS.md", + "pnpm-lock.yaml", + "prd.html" + ] } diff --git a/.trellis/spec/chrome-extension/frontend/quality-guidelines.md b/.trellis/spec/chrome-extension/frontend/quality-guidelines.md index ff5d894..18fc516 100644 --- a/.trellis/spec/chrome-extension/frontend/quality-guidelines.md +++ b/.trellis/spec/chrome-extension/frontend/quality-guidelines.md @@ -8,6 +8,7 @@ - 仓库统一使用 Oxfmt;lint-staged 只格式化暂存文件,Husky 在 `pre-commit` 阶段触发该检查。 - 扩展包使用 Vite `6.x` 和 TypeScript `5.7.x`。`typecheck` 执行 `tsc -p tsconfig.json --noEmit`,`build` 先检查类型再执行 `vite build`,`test` 使用 Node 内置 test runner;尚无 lint 脚本。 - Vite 依赖的 `esbuild` 安装脚本在根 `pnpm-workspace.yaml` 的 `allowBuilds` 中显式允许。 +- 代码格式统一使用根目录 Oxfmt,缩进为 4 个空格;VSCode 保存格式化使用工作区推荐的 `oxc.oxc-vscode`,不得使用其它 formatter。 ## 必须遵守 @@ -32,6 +33,8 @@ pnpm build 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`。新增非平凡逻辑时,应补充最小可运行测试。 ## 评审清单 @@ -43,3 +46,4 @@ pnpm test - Popup 移动后,Vite input、TypeScript include、Manifest 路径和 `dist/` 产物是否一致? - 是否覆盖了用户可见的加载、空、错误和键盘交互状态? - 是否执行了当前可用的根级验证命令,并如实说明跳过项? +- 编辑器保存后的代码是否仍通过 Oxfmt 检查,并保持 4 个空格缩进? diff --git a/.trellis/spec/project/architecture.md b/.trellis/spec/project/architecture.md index 8047971..c4cd131 100644 --- a/.trellis/spec/project/architecture.md +++ b/.trellis/spec/project/architecture.md @@ -174,6 +174,16 @@ types / constants 这里的“主函数”是文件中负责组织其它声明完成该文件主要职责的函数,通常是入口函数、编排函数或主要公开函数;不是按函数长度判断,也不是强制命名为 `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 | 发现的代码形态 | 处理 | @@ -190,6 +200,8 @@ types / constants | 文件头缺少职责注释,或正文少于 10 / 多于 30 个字符 | 补充或改写为 10–30 个字符的职责描述 | | 入口文件头只描述“这是入口” | 改为描述入口所在目录的整体职责 | | 有主函数但主函数前没有独立职责注释 | 在主函数声明正上方补充职责说明 | +| 编辑器保存后与 Oxfmt 结果不同 | 将保存 formatter 切换为 Oxc,或关闭保存格式化后运行 `pnpm format` | +| 代码使用 2 空格或 Tab,与项目约定不一致 | 按 `.oxfmtrc.json` 和 `.editorconfig` 统一为 4 个空格 | ## 5. Good / Base / Bad Cases @@ -208,6 +220,8 @@ types / constants - 入口、分发和分支重构应保留原有行为测试,证明只改变职责归属,没有改变输出契约。 - 新增或修改手写代码时,检查文件第一行职责注释的正文长度为 10–30 个字符;入口文件还要检查注释描述的是目录职责。 - 检查主函数前存在独立职责注释,且主函数是最后一个函数声明;允许其后出现直接启动调用或显式导出。 +- 检查手工保存后的文件通过 `pnpm format:check`,且 VSCode 使用 `oxc.oxc-vscode` 读取根 `.oxfmtrc.json`。 +- 检查代码缩进为 4 个空格,未混入 Tab、Prettier 或其它 formatter 的结果。 ## 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 // 组装并启动当前目录的历史同步能力 import { fetchPage } from "./sdk.ts"; @@ -286,6 +321,7 @@ export function syncHistory(): void { - `utils.ts` 是否仍然不含业务流程、重要类型和业务常量? - 基础原语是否保持无业务语义,而不是通过业务化命名制造无行为差异的包装? - 重要常量是否位于使用它的所有者或功能级 `constants.ts`? +- 编辑器保存是否与 Oxfmt 结果一致,并且缩进是否统一为 4 个空格? - 文件第一行是否有 10–30 个字符的职责注释,入口文件是否描述目录职责? - 主函数是否有独立职责注释并位于所有依赖声明之后? - 入口函数是否是最后一个函数声明,其后是否只有启动调用或显式导出? diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 0000000..9d5eb3d --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["oxc.oxc-vscode"] +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..282d976 --- /dev/null +++ b/.vscode/settings.json @@ -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 + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..77ee033 --- /dev/null +++ b/README.md @@ -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。 diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index f65b1d4..cdbcb6c 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -1,17 +1,17 @@ { - "name": "@trade-message-center/chrome-extension", - "version": "0.5.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite build --watch --mode development", - "build": "tsc -p tsconfig.json && vite build", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "node --experimental-strip-types --test test/*.test.js" - }, - "devDependencies": { - "@types/node": "^22.10.2", - "typescript": "^5.7.3", - "vite": "^6.0.7" - } + "name": "@trade-message-center/chrome-extension", + "version": "0.5.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite build --watch --mode development", + "build": "tsc -p tsconfig.json && vite build", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "node --experimental-strip-types --test test/*.test.js" + }, + "devDependencies": { + "@types/node": "^22.10.2", + "typescript": "^5.7.3", + "vite": "^6.0.7" + } } diff --git a/apps/chrome-extension/popup/popup.html b/apps/chrome-extension/popup/popup.html index c0b8554..909d1d1 100644 --- a/apps/chrome-extension/popup/popup.html +++ b/apps/chrome-extension/popup/popup.html @@ -1,37 +1,37 @@ - - - - Trade Message Center - - - -

Trade Message Center

-

正在读取当前页面…

-

- - + + + + Trade Message Center + + + +

Trade Message Center

+

正在读取当前页面…

+

+ + diff --git a/apps/chrome-extension/popup/popup.ts b/apps/chrome-extension/popup/popup.ts index 9ca0e3d..64e9c4e 100644 --- a/apps/chrome-extension/popup/popup.ts +++ b/apps/chrome-extension/popup/popup.ts @@ -1,14 +1,14 @@ // 读取并展示当前浏览器标签页信息 interface ChromeTab { - title?: string; - url?: string; + title?: string; + url?: string; } interface ChromeApi { - tabs: { - query(queryInfo: { active: true; currentWindow: true }): Promise; - }; + tabs: { + query(queryInfo: { active: true; currentWindow: true }): Promise; + }; } const chromeApi = (globalThis as unknown as { chrome: ChromeApi }).chrome; @@ -21,14 +21,14 @@ const url = urlElement; /** 查询当前活动标签页并更新弹窗文本。 */ async function renderCurrentTab(): Promise { - try { - const [tab] = await chromeApi.tabs.query({ active: true, currentWindow: true }); - title.textContent = tab?.title || "无法读取当前页面"; - url.textContent = tab?.url || ""; - } catch { - title.textContent = "无法读取当前页面"; - url.textContent = ""; - } + try { + const [tab] = await chromeApi.tabs.query({ active: true, currentWindow: true }); + title.textContent = tab?.title || "无法读取当前页面"; + url.textContent = tab?.url || ""; + } catch { + title.textContent = "无法读取当前页面"; + url.textContent = ""; + } } void renderCurrentTab(); diff --git a/apps/chrome-extension/public/manifest.json b/apps/chrome-extension/public/manifest.json index 1ee317a..94916aa 100644 --- a/apps/chrome-extension/public/manifest.json +++ b/apps/chrome-extension/public/manifest.json @@ -1,29 +1,29 @@ { - "manifest_version": 3, - "name": "Trade Message Center", - "version": "0.5.0", - "description": "Sync authorized Alibaba, Made in China, and Global Sources conversations to TradeBridge.", - "icons": { - "16": "icons/icon-16.png", - "32": "icons/icon-32.png", - "48": "icons/icon-48.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" + "manifest_version": 3, + "name": "Trade Message Center", + "version": "0.5.0", + "description": "Sync authorized Alibaba, Made in China, and Global Sources conversations to TradeBridge.", + "icons": { + "16": "icons/icon-16.png", + "32": "icons/icon-32.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.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" + } } diff --git a/apps/chrome-extension/src/lib/error.ts b/apps/chrome-extension/src/lib/error.ts index 61f27b2..1a1e0c3 100644 --- a/apps/chrome-extension/src/lib/error.ts +++ b/apps/chrome-extension/src/lib/error.ts @@ -2,5 +2,5 @@ /** 以错误消息终止当前控制流。 */ export function fail(message: string): never { - throw new Error(message); + throw new Error(message); } diff --git a/apps/chrome-extension/src/lib/websocket-observer.ts b/apps/chrome-extension/src/lib/websocket-observer.ts index e5b000a..299eb54 100644 --- a/apps/chrome-extension/src/lib/websocket-observer.ts +++ b/apps/chrome-extension/src/lib/websocket-observer.ts @@ -1,31 +1,31 @@ // 代理浏览器 WebSocket 并安全旁路消息 export interface WebSocketHost { - WebSocket: typeof WebSocket; + WebSocket: typeof WebSocket; } /** 为匹配的 WebSocket 消息注册旁路观察回调。 */ export function observeNewWebSocketMessages( - host: WebSocketHost, - matches: (url: string) => boolean, - observe: (data: unknown) => void, + host: WebSocketHost, + matches: (url: string) => boolean, + observe: (data: unknown) => void, ): void { - const NativeWebSocket = host.WebSocket; + const NativeWebSocket = host.WebSocket; - host.WebSocket = new Proxy(NativeWebSocket, { - construct(target, args, newTarget) { - const socket = Reflect.construct(target, args, newTarget) as WebSocket; - const url = String(args[0]); + host.WebSocket = new Proxy(NativeWebSocket, { + construct(target, args, newTarget) { + const socket = Reflect.construct(target, args, newTarget) as WebSocket; + const url = String(args[0]); - try { - if (matches(url)) { - socket.addEventListener("message", (event) => observe(event.data)); - } - } catch { - // Observation must never affect the page's WebSocket behavior. - } + try { + if (matches(url)) { + socket.addEventListener("message", (event) => observe(event.data)); + } + } catch { + // Observation must never affect the page's WebSocket behavior. + } - return socket; - }, - }); + return socket; + }, + }); } diff --git a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/entry.ts b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/entry.ts index 8b80252..0ad5cb9 100644 --- a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/entry.ts @@ -6,8 +6,8 @@ import { syncCurrentConversationHistory } from "./index.ts"; /** 将当前会话历史同步能力注册到页面命名空间。 */ export function installCurrentConversationHistorySync(pageWindow: OneTalkPageWindow): void { - const existing = pageWindow.__tradeMessageCenterOneTalk; - const namespace: Record = isRecord(existing) ? existing : {}; - namespace.syncCurrentConversationHistory = () => syncCurrentConversationHistory(pageWindow); - pageWindow.__tradeMessageCenterOneTalk = namespace; + const existing = pageWindow.__tradeMessageCenterOneTalk; + const namespace: Record = isRecord(existing) ? existing : {}; + namespace.syncCurrentConversationHistory = () => syncCurrentConversationHistory(pageWindow); + pageWindow.__tradeMessageCenterOneTalk = namespace; } diff --git a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/index.ts b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/index.ts index 5621797..9032c83 100644 --- a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/index.ts +++ b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/index.ts @@ -3,102 +3,102 @@ import { fail } from "../../../lib/error.ts"; import type { OneTalkPageWindow } from "../model.ts"; import { - activeAccountId, - assertActiveAccount, - fetchMessagesWithoutUpdateToRead, - findCurrentConversation, - messageService, - pageSdk, + activeAccountId, + assertActiveAccount, + fetchMessagesWithoutUpdateToRead, + findCurrentConversation, + messageService, + pageSdk, } from "./sdk.ts"; import { - historyErrorCodes, - type CurrentConversationHistorySyncResult, - type SyncDependencies, + historyErrorCodes, + type CurrentConversationHistorySyncResult, + type SyncDependencies, } from "./model.ts"; const MIN_PAGE_DELAY_MS = 1_000; const MAX_PAGE_DELAY_MS = 3_000; const inFlightSyncs = new WeakMap< - OneTalkPageWindow, - Promise + OneTalkPageWindow, + Promise >(); function pageDelay(random: () => number): number { - const value = random(); - if (!Number.isFinite(value) || value < 0 || value > 1) { - fail(historyErrorCodes.invalidRandomValue); - } - const range = MAX_PAGE_DELAY_MS - MIN_PAGE_DELAY_MS; - return MIN_PAGE_DELAY_MS + Math.min(range, Math.floor(value * (range + 1))); + const value = random(); + if (!Number.isFinite(value) || value < 0 || value > 1) { + fail(historyErrorCodes.invalidRandomValue); + } + const range = MAX_PAGE_DELAY_MS - MIN_PAGE_DELAY_MS; + return MIN_PAGE_DELAY_MS + Math.min(range, Math.floor(value * (range + 1))); } async function runSync( - pageWindow: OneTalkPageWindow, - dependencies: SyncDependencies, + pageWindow: OneTalkPageWindow, + dependencies: SyncDependencies, ): Promise { - const now = dependencies.now ?? Date.now; - const random = dependencies.random ?? Math.random; - const sleep = - dependencies.sleep ?? - ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); + const now = dependencies.now ?? Date.now; + const random = dependencies.random ?? Math.random; + const sleep = + dependencies.sleep ?? + ((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds))); - const accountId = activeAccountId(pageWindow); - const firstTimeStamp = now(); - if (!Number.isFinite(firstTimeStamp)) fail(historyErrorCodes.invalidTime); - const sdk = pageSdk(pageWindow); - const conversation = await findCurrentConversation(pageWindow, sdk, accountId); - assertActiveAccount(pageWindow, accountId); - const service = messageService(sdk); - - const messageIds = new Set(); - let pages = 0; - let timeStamp = firstTimeStamp; - while (true) { - assertActiveAccount(pageWindow, accountId); - const page = await fetchMessagesWithoutUpdateToRead(service, conversation, timeStamp); + const accountId = activeAccountId(pageWindow); + const firstTimeStamp = now(); + if (!Number.isFinite(firstTimeStamp)) fail(historyErrorCodes.invalidTime); + const sdk = pageSdk(pageWindow); + const conversation = await findCurrentConversation(pageWindow, sdk, accountId); assertActiveAccount(pageWindow, accountId); + const service = messageService(sdk); - let oldestTime = Number.POSITIVE_INFINITY; - let newMessages = 0; - for (const message of page.messages) { - oldestTime = Math.min(oldestTime, message.sendTime); - 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 }; - } + const messageIds = new Set(); + let pages = 0; + let timeStamp = firstTimeStamp; + while (true) { + assertActiveAccount(pageWindow, accountId); + const page = await fetchMessagesWithoutUpdateToRead(service, conversation, timeStamp); + assertActiveAccount(pageWindow, accountId); - timeStamp = oldestTime; - try { - await sleep(pageDelay(random)); - } catch { - fail(historyErrorCodes.sleepFailed); + let oldestTime = Number.POSITIVE_INFINITY; + let newMessages = 0; + for (const message of page.messages) { + oldestTime = Math.min(oldestTime, message.sendTime); + 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( - pageWindow: OneTalkPageWindow, - dependencies: SyncDependencies = {}, + pageWindow: OneTalkPageWindow, + dependencies: SyncDependencies = {}, ): Promise { - const existing = inFlightSyncs.get(pageWindow); - if (existing) return existing; + const existing = inFlightSyncs.get(pageWindow); + if (existing) return existing; - const sync = runSync(pageWindow, dependencies); - inFlightSyncs.set(pageWindow, sync); - const clear = (): void => { - if (inFlightSyncs.get(pageWindow) === sync) inFlightSyncs.delete(pageWindow); - }; - void sync.then(clear, clear); - return sync; + const sync = runSync(pageWindow, dependencies); + inFlightSyncs.set(pageWindow, sync); + const clear = (): void => { + if (inFlightSyncs.get(pageWindow) === sync) inFlightSyncs.delete(pageWindow); + }; + void sync.then(clear, clear); + return sync; } export type { CurrentConversationHistorySyncResult, SyncDependencies } from "./model.ts"; diff --git a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/model.ts b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/model.ts index 7d72a0e..8db1d57 100644 --- a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/model.ts +++ b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/model.ts @@ -5,143 +5,143 @@ import { isAccountId, type OneTalkAccountId } from "../model.ts"; import { isRecord } from "../utils.ts"; export const historyErrorCodes = { - activeAccountChanged: "onetalk_history_active_account_changed", - conversationCursorStalled: "onetalk_history_conversation_cursor_stalled", - conversationRequestFailed: "onetalk_history_conversation_request_failed", - currentConversationNotFound: "onetalk_history_current_conversation_not_found", - invalidActiveAccount: "onetalk_history_invalid_active_account", - invalidConversationPage: "onetalk_history_invalid_conversation_page", - invalidMessagePage: "onetalk_history_invalid_message_page", - invalidRandomValue: "onetalk_history_invalid_random_value", - invalidTime: "onetalk_history_invalid_time", - messageCursorStalled: "onetalk_history_message_cursor_stalled", - messageRequestFailed: "onetalk_history_message_request_failed", - sdkUnavailable: "onetalk_history_sdk_unavailable", - sleepFailed: "onetalk_history_sleep_failed", + activeAccountChanged: "onetalk_history_active_account_changed", + conversationCursorStalled: "onetalk_history_conversation_cursor_stalled", + conversationRequestFailed: "onetalk_history_conversation_request_failed", + currentConversationNotFound: "onetalk_history_current_conversation_not_found", + invalidActiveAccount: "onetalk_history_invalid_active_account", + invalidConversationPage: "onetalk_history_invalid_conversation_page", + invalidMessagePage: "onetalk_history_invalid_message_page", + invalidRandomValue: "onetalk_history_invalid_random_value", + invalidTime: "onetalk_history_invalid_time", + messageCursorStalled: "onetalk_history_message_cursor_stalled", + messageRequestFailed: "onetalk_history_message_request_failed", + sdkUnavailable: "onetalk_history_sdk_unavailable", + sleepFailed: "onetalk_history_sleep_failed", } as const; export type HistoryErrorCode = (typeof historyErrorCodes)[keyof typeof historyErrorCodes]; export type Cursor = string | number; export type Conversation = Record & { - cid: string; - accountId: OneTalkAccountId; - accountIdEncrypt: string; - aliId: string; - aliIdEncrypt?: string; + cid: string; + accountId: OneTalkAccountId; + accountIdEncrypt: string; + aliId: string; + aliIdEncrypt?: string; }; export type ConversationPage = { - list: unknown[]; - hasMore: boolean; - nextCursor?: unknown; + list: unknown[]; + hasMore: boolean; + nextCursor?: unknown; }; export type HistoryMessage = { - messageIdKey: string; - sendTime: number; + messageIdKey: string; + sendTime: number; }; export type HistoryMessagePage = { - messages: HistoryMessage[]; - hasMore: boolean; + messages: HistoryMessage[]; + hasMore: boolean; }; export type SyncDependencies = { - now?: () => number; - random?: () => number; - sleep?: (milliseconds: number) => Promise; + now?: () => number; + random?: () => number; + sleep?: (milliseconds: number) => Promise; }; export type CurrentConversationHistorySyncResult = { - exit: "history_exhausted"; - pages: number; - uniqueMessages: number; + exit: "history_exhausted"; + pages: number; + uniqueMessages: number; }; 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 { - return ( - (typeof value === "string" && value.length > 0) || - (typeof value === "number" && Number.isFinite(value)) - ); + return ( + (typeof value === "string" && value.length > 0) || + (typeof value === "number" && Number.isFinite(value)) + ); } export function normalizePageSdk(value: unknown): Record { - if (!isRecord(value) || !isRecord(value.IMBaaSSDK) || !isRecord(value.IMBaaSSDK.default)) { - fail(historyErrorCodes.sdkUnavailable); - } - return value.IMBaaSSDK.default; + if (!isRecord(value) || !isRecord(value.IMBaaSSDK) || !isRecord(value.IMBaaSSDK.default)) { + fail(historyErrorCodes.sdkUnavailable); + } + return value.IMBaaSSDK.default; } export function normalizeSdkService(value: unknown): Record { - return isRecord(value) ? value : fail(historyErrorCodes.sdkUnavailable); + return isRecord(value) ? value : fail(historyErrorCodes.sdkUnavailable); } function normalizeConversation(value: unknown, accountId: string): Conversation | null { - if (!isRecord(value)) fail(historyErrorCodes.invalidConversationPage); - if (!isAccountId(value.accountId) || String(value.accountId) !== accountId) return null; - if ( - typeof value.cid !== "string" || - value.cid.length === 0 || - typeof value.accountIdEncrypt !== "string" || - value.accountIdEncrypt.length === 0 || - typeof value.aliId !== "string" || - value.aliId.length === 0 || - (value.aliIdEncrypt !== undefined && - (typeof value.aliIdEncrypt !== "string" || value.aliIdEncrypt.length === 0)) - ) { - fail(historyErrorCodes.invalidConversationPage); - } - return value as Conversation; + if (!isRecord(value)) fail(historyErrorCodes.invalidConversationPage); + if (!isAccountId(value.accountId) || String(value.accountId) !== accountId) return null; + if ( + typeof value.cid !== "string" || + value.cid.length === 0 || + typeof value.accountIdEncrypt !== "string" || + value.accountIdEncrypt.length === 0 || + typeof value.aliId !== "string" || + value.aliId.length === 0 || + (value.aliIdEncrypt !== undefined && + (typeof value.aliIdEncrypt !== "string" || value.aliIdEncrypt.length === 0)) + ) { + fail(historyErrorCodes.invalidConversationPage); + } + return value as Conversation; } export function normalizeConversationPage(value: unknown): ConversationPage { - if (!isRecord(value) || !Array.isArray(value.list) || typeof value.hasMore !== "boolean") { - fail(historyErrorCodes.invalidConversationPage); - } - return { list: value.list, hasMore: value.hasMore, nextCursor: value.nextCursor }; + if (!isRecord(value) || !Array.isArray(value.list) || typeof value.hasMore !== "boolean") { + fail(historyErrorCodes.invalidConversationPage); + } + return { list: value.list, hasMore: value.hasMore, nextCursor: value.nextCursor }; } export function findMatchingConversation( - values: unknown[], - accountId: string, + values: unknown[], + accountId: string, ): Conversation | null { - for (const value of values) { - const conversation = normalizeConversation(value, accountId); - if (conversation) return conversation; - } - return null; + for (const value of values) { + const conversation = normalizeConversation(value, accountId); + if (conversation) return conversation; + } + return null; } function normalizeHistoryMessage(value: unknown): HistoryMessage { - if (!isRecord(value)) fail(historyErrorCodes.invalidMessagePage); - const messageId = value.messageId; - const sendTime = value.sendTime; - if ( - !( - (typeof messageId === "string" && messageId.length > 0) || - (typeof messageId === "number" && Number.isFinite(messageId)) - ) || - typeof sendTime !== "number" || - !Number.isFinite(sendTime) - ) { - fail(historyErrorCodes.invalidMessagePage); - } - return { messageIdKey: String(messageId), sendTime }; + if (!isRecord(value)) fail(historyErrorCodes.invalidMessagePage); + const messageId = value.messageId; + const sendTime = value.sendTime; + if ( + !( + (typeof messageId === "string" && messageId.length > 0) || + (typeof messageId === "number" && Number.isFinite(messageId)) + ) || + typeof sendTime !== "number" || + !Number.isFinite(sendTime) + ) { + fail(historyErrorCodes.invalidMessagePage); + } + return { messageIdKey: String(messageId), sendTime }; } export function normalizeHistoryMessagePage(value: unknown): HistoryMessagePage { - if ( - !isRecord(value) || - !Array.isArray(value.list) || - value.list.length === 0 || - typeof value.hasMore !== "boolean" - ) { - fail(historyErrorCodes.invalidMessagePage); - } - return { messages: value.list.map(normalizeHistoryMessage), hasMore: value.hasMore }; + if ( + !isRecord(value) || + !Array.isArray(value.list) || + value.list.length === 0 || + typeof value.hasMore !== "boolean" + ) { + fail(historyErrorCodes.invalidMessagePage); + } + return { messages: value.list.map(normalizeHistoryMessage), hasMore: value.hasMore }; } diff --git a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts index 02441be..bd5200b 100644 --- a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts +++ b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts @@ -2,131 +2,131 @@ import { fail } from "../../../lib/error.ts"; import { - findMatchingConversation, - historyErrorCodes, - isCursor, - normalizeActiveAccountId, - normalizeConversationPage, - normalizeHistoryMessagePage, - normalizePageSdk, - normalizeSdkService, - type Conversation, - type Cursor, - type HistoryMessagePage, + findMatchingConversation, + historyErrorCodes, + isCursor, + normalizeActiveAccountId, + normalizeConversationPage, + normalizeHistoryMessagePage, + normalizePageSdk, + normalizeSdkService, + type Conversation, + type Cursor, + type HistoryMessagePage, } from "./model.ts"; import type { OneTalkPageWindow } from "../model.ts"; const HISTORY_PAGE_SIZE = 20; function conversationCursorKey(cursor: Cursor): string { - return `${typeof cursor}:${cursor}`; + return `${typeof cursor}:${cursor}`; } export function activeAccountId(pageWindow: OneTalkPageWindow): string { - try { - return normalizeActiveAccountId( - new URL(pageWindow.location.href).searchParams.get("activeAccountId"), - ); - } catch (error) { - if (error instanceof Error && error.message === historyErrorCodes.invalidActiveAccount) { - throw error; + try { + return normalizeActiveAccountId( + new URL(pageWindow.location.href).searchParams.get("activeAccountId"), + ); + } catch (error) { + if (error instanceof Error && error.message === historyErrorCodes.invalidActiveAccount) { + throw error; + } + return fail(historyErrorCodes.invalidActiveAccount); } - return fail(historyErrorCodes.invalidActiveAccount); - } } export function assertActiveAccount(pageWindow: OneTalkPageWindow, expected: string): void { - if (activeAccountId(pageWindow) !== expected) { - fail(historyErrorCodes.activeAccountChanged); - } + if (activeAccountId(pageWindow) !== expected) { + fail(historyErrorCodes.activeAccountChanged); + } } export function pageSdk(pageWindow: OneTalkPageWindow): Record { - return normalizePageSdk(pageWindow.IcbuIM); + return normalizePageSdk(pageWindow.IcbuIM); } export function messageService(sdk: Record): Record { - const getMessageService = sdk.getMessageService; - if (typeof getMessageService !== "function") { - fail(historyErrorCodes.sdkUnavailable); - } + const getMessageService = sdk.getMessageService; + if (typeof getMessageService !== "function") { + fail(historyErrorCodes.sdkUnavailable); + } - try { - return normalizeSdkService(getMessageService.call(sdk)); - } catch { - return fail(historyErrorCodes.sdkUnavailable); - } + try { + return normalizeSdkService(getMessageService.call(sdk)); + } catch { + return fail(historyErrorCodes.sdkUnavailable); + } } export async function fetchMessagesWithoutUpdateToRead( - service: Record, - conversation: Conversation, - timeStamp: number, + service: Record, + conversation: Conversation, + timeStamp: number, ): Promise { - const fetchPage = service.fetchMessagesWithoutUpdateToRead; - if (typeof fetchPage !== "function") fail(historyErrorCodes.sdkUnavailable); + const fetchPage = service.fetchMessagesWithoutUpdateToRead; + if (typeof fetchPage !== "function") fail(historyErrorCodes.sdkUnavailable); - let response: unknown; - try { - const aliIdEncrypt = conversation.aliIdEncrypt; - response = await fetchPage.call( - service, - { - contactAccountId: conversation.accountId, - contactAccountIdEncrypt: conversation.accountIdEncrypt, - aliId: conversation.aliId, - ...(aliIdEncrypt === undefined ? {} : { aliIdEncrypt }), - searchMessageId: "", - timeSlide: { forward: false, timeStamp, pageSize: HISTORY_PAGE_SIZE }, - }, - conversation, - ); - } catch { - return fail(historyErrorCodes.messageRequestFailed); - } - return normalizeHistoryMessagePage(response); + let response: unknown; + try { + const aliIdEncrypt = conversation.aliIdEncrypt; + response = await fetchPage.call( + service, + { + contactAccountId: conversation.accountId, + contactAccountIdEncrypt: conversation.accountIdEncrypt, + aliId: conversation.aliId, + ...(aliIdEncrypt === undefined ? {} : { aliIdEncrypt }), + searchMessageId: "", + timeSlide: { forward: false, timeStamp, pageSize: HISTORY_PAGE_SIZE }, + }, + conversation, + ); + } catch { + return fail(historyErrorCodes.messageRequestFailed); + } + return normalizeHistoryMessagePage(response); } /** 扫描会话分页并返回与当前账号匹配的会话。 */ export async function findCurrentConversation( - pageWindow: OneTalkPageWindow, - sdk: Record, - accountId: string, + pageWindow: OneTalkPageWindow, + sdk: Record, + accountId: string, ): Promise { - const getService = sdk.getConversationServiceV2; - if (typeof getService !== "function") fail(historyErrorCodes.sdkUnavailable); + const getService = sdk.getConversationServiceV2; + if (typeof getService !== "function") fail(historyErrorCodes.sdkUnavailable); - let service: Record; - 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(); - while (true) { - assertActiveAccount(pageWindow, accountId); - const key = conversationCursorKey(cursor); - if (seenCursors.has(key)) fail(historyErrorCodes.conversationCursorStalled); - seenCursors.add(key); - - let response: unknown; + let service: Record; try { - response = await getPage.call(service, { cursor, count: HISTORY_PAGE_SIZE }); + service = normalizeSdkService(getService.call(sdk)); } catch { - return fail(historyErrorCodes.conversationRequestFailed); + return fail(historyErrorCodes.sdkUnavailable); } - 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); + const getPage = service.getConversationListByPagination; + if (typeof getPage !== "function") fail(historyErrorCodes.sdkUnavailable); + + let cursor: Cursor = 0; + const seenCursors = new Set(); + while (true) { + 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; - } } diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts index c876dd1..a70fa6c 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts @@ -4,5 +4,5 @@ import { installOneTalkWebSocketTap, type OneTalkWebSocketPageWindow } from "./w /** 组合并安装 OneTalk 消息观察入口。 */ export function installOneTalkMessageObserver(pageWindow: OneTalkWebSocketPageWindow): void { - installOneTalkWebSocketTap(pageWindow); + installOneTalkWebSocketTap(pageWindow); } diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts index d249137..d758085 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts @@ -5,18 +5,18 @@ import { conversationParticipants, observedMessage, type ObservedOneTalkMessage /** 将历史消息响应条目转换为可观察消息。 */ export function parseHistoryMessages(items: unknown[]): ObservedOneTalkMessage[] { - return items.flatMap((item) => { - if (!isRecord(item) || !isRecord(item.message)) return []; - const participantIds = conversationParticipants(item.message.cid); - if (!participantIds) return []; + return items.flatMap((item) => { + if (!isRecord(item) || !isRecord(item.message)) return []; + const participantIds = conversationParticipants(item.message.cid); + if (!participantIds) return []; - const message = observedMessage( - item.message, - participantIds, - item.readStatus, - item.msgStatus, - "history", - ); - return message ? [message] : []; - }); + const message = observedMessage( + item.message, + participantIds, + item.readStatus, + item.msgStatus, + "history", + ); + return message ? [message] : []; + }); } diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts index d3c74d6..1cd78a1 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts @@ -7,20 +7,20 @@ import type { ObservedOneTalkMessage } from "./model.ts"; /** 识别响应帧并委派到历史或新消息解析器。 */ export function parseOneTalkMessages(data: unknown): ObservedOneTalkMessage[] { - if (typeof data !== "string") return []; + if (typeof data !== "string") return []; - let frame: unknown; - try { - frame = JSON.parse(data); - } catch { - return []; - } + let frame: unknown; + try { + frame = JSON.parse(data); + } catch { + return []; + } - if (!isRecord(frame) || frame.code !== 200) return []; + if (!isRecord(frame) || frame.code !== 200) return []; - if (isRecord(frame.body) && Array.isArray(frame.body.userMessageModels)) { - return parseHistoryMessages(frame.body.userMessageModels); - } + if (isRecord(frame.body) && Array.isArray(frame.body.userMessageModels)) { + return parseHistoryMessages(frame.body.userMessageModels); + } - return Array.isArray(frame.body) ? parseNewMessages(frame.body) : []; + return Array.isArray(frame.body) ? parseNewMessages(frame.body) : []; } diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts index 0682c92..564ff7c 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts @@ -3,71 +3,71 @@ import { isRecord } from "../utils.ts"; export interface ObservedOneTalkMessage { - messageType: "new" | "history"; - conversationId: string; - messageId: string; - sentAt: number; - contentType: number; - text: string | null; - senderId: string; - participantIds: [string, string]; - direction: "sent" | "received"; - readStatus: number; - messageStatus: number; - unreadCount: number; + messageType: "new" | "history"; + conversationId: string; + messageId: string; + sentAt: number; + contentType: number; + text: string | null; + senderId: string; + participantIds: [string, string]; + direction: "sent" | "received"; + readStatus: number; + messageStatus: number; + unreadCount: number; } export function conversationParticipants(value: unknown): [string, string] | null { - if (typeof value !== "string") return null; - const match = /^([^-]+)-([^#]+)#[^@]+@(.+)$/.exec(value); - return match ? [`${match[1]}@${match[3]}`, `${match[2]}@${match[3]}`] : null; + if (typeof value !== "string") return null; + const match = /^([^-]+)-([^#]+)#[^@]+@(.+)$/.exec(value); + return match ? [`${match[1]}@${match[3]}`, `${match[2]}@${match[3]}`] : null; } /** 校验消息字段并生成统一的观察消息模型。 */ export function observedMessage( - message: Record, - participantIds: [string, string], - readStatus: unknown, - messageStatus: unknown, - messageType: ObservedOneTalkMessage["messageType"], + message: Record, + participantIds: [string, string], + readStatus: unknown, + messageStatus: unknown, + messageType: ObservedOneTalkMessage["messageType"], ): ObservedOneTalkMessage | null { - if (!isRecord(message.content) || !isRecord(message.sender)) return null; - const text = message.content.text; + if (!isRecord(message.content) || !isRecord(message.sender)) return null; + const text = message.content.text; - const conversationId = message.cid; - const messageId = message.messageId; - const sentAt = message.createAt; - const contentType = message.content.contentType; - const content = isRecord(text) && typeof text.content === "string" ? text.content : null; - const senderId = message.sender.uid; - const unreadCount = message.unreadCount; + const conversationId = message.cid; + const messageId = message.messageId; + const sentAt = message.createAt; + const contentType = message.content.contentType; + const content = isRecord(text) && typeof text.content === "string" ? text.content : null; + const senderId = message.sender.uid; + const unreadCount = message.unreadCount; - if ( - typeof conversationId !== "string" || - typeof messageId !== "string" || - typeof sentAt !== "number" || - typeof contentType !== "number" || - typeof senderId !== "string" || - !participantIds.includes(senderId) || - typeof readStatus !== "number" || - typeof messageStatus !== "number" || - typeof unreadCount !== "number" - ) { - return null; - } + if ( + typeof conversationId !== "string" || + typeof messageId !== "string" || + typeof sentAt !== "number" || + typeof contentType !== "number" || + typeof senderId !== "string" || + !participantIds.includes(senderId) || + typeof readStatus !== "number" || + typeof messageStatus !== "number" || + typeof unreadCount !== "number" + ) { + return null; + } - return { - messageType, - conversationId, - messageId, - sentAt, - contentType, - text: content, - senderId, - participantIds, - direction: senderId === participantIds[1] ? "sent" : "received", - readStatus, - messageStatus, - unreadCount, - }; + return { + messageType, + conversationId, + messageId, + sentAt, + contentType, + text: content, + senderId, + participantIds, + direction: senderId === participantIds[1] ? "sent" : "received", + readStatus, + messageStatus, + unreadCount, + }; } diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts index c7c585d..1f6c628 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts @@ -4,37 +4,37 @@ import { isRecord } from "../utils.ts"; import { observedMessage, type ObservedOneTalkMessage } from "./model.ts"; function parseNewMessage(conversation: Record): ObservedOneTalkMessage | null { - const lastMessage = conversation.lastMessage; - const singleChatConversation = conversation.singleChatConversation; - if (!isRecord(lastMessage) || !isRecord(singleChatConversation)) return null; + const lastMessage = conversation.lastMessage; + const singleChatConversation = conversation.singleChatConversation; + if (!isRecord(lastMessage) || !isRecord(singleChatConversation)) return null; - const pairFirst = singleChatConversation.pairFirst; - const pairSecond = singleChatConversation.pairSecond; + const pairFirst = singleChatConversation.pairFirst; + const pairSecond = singleChatConversation.pairSecond; - if ( - typeof pairFirst !== "string" || - typeof pairSecond !== "string" || - !isRecord(lastMessage.message) - ) { - return null; - } + if ( + typeof pairFirst !== "string" || + typeof pairSecond !== "string" || + !isRecord(lastMessage.message) + ) { + return null; + } - return observedMessage( - lastMessage.message, - [pairFirst, pairSecond], - lastMessage.readStatus, - lastMessage.msgStatus, - "new", - ); + return observedMessage( + lastMessage.message, + [pairFirst, pairSecond], + lastMessage.readStatus, + lastMessage.msgStatus, + "new", + ); } /** 将新消息响应条目转换为可观察消息。 */ export function parseNewMessages(items: unknown[]): ObservedOneTalkMessage[] { - const messages: ObservedOneTalkMessage[] = []; - for (const item of items) { - if (!isRecord(item) || !isRecord(item.singleChatUserConversation)) continue; - const message = parseNewMessage(item.singleChatUserConversation); - if (message) messages.push(message); - } - return messages; + const messages: ObservedOneTalkMessage[] = []; + for (const item of items) { + if (!isRecord(item) || !isRecord(item.singleChatUserConversation)) continue; + const message = parseNewMessage(item.singleChatUserConversation); + if (message) messages.push(message); + } + return messages; } diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts index 528e186..d4e7fbd 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts @@ -1,8 +1,8 @@ // 接入 OneTalk WebSocket 并旁路记录消息 import { - observeNewWebSocketMessages, - type WebSocketHost, + observeNewWebSocketMessages, + type WebSocketHost, } from "../../../lib/websocket-observer.ts"; import type { OneTalkPageWindow } from "../model.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"; export interface OneTalkWebSocketPageWindow extends OneTalkPageWindow, WebSocketHost { - [INSTALL_KEY]?: boolean; - console: Pick; + [INSTALL_KEY]?: boolean; + console: Pick; } function isWebSocketUrlForHost(url: string, hostname: string): boolean { - try { - const parsed = new URL(url); - return parsed.protocol === "wss:" && parsed.hostname === hostname; - } catch { - return false; - } + try { + const parsed = new URL(url); + return parsed.protocol === "wss:" && parsed.hostname === hostname; + } catch { + return false; + } } /** 安装只观察指定 OneTalk 主机的 WebSocket 旁路。 */ export function installOneTalkWebSocketTap(pageWindow: OneTalkWebSocketPageWindow): void { - if (pageWindow[INSTALL_KEY]) return; - pageWindow[INSTALL_KEY] = true; + if (pageWindow[INSTALL_KEY]) return; + pageWindow[INSTALL_KEY] = true; - observeNewWebSocketMessages( - pageWindow, - (url) => isWebSocketUrlForHost(url, ONETALK_WEBSOCKET_HOST), - (data) => { - try { - pageWindow.console.log(LOG_PREFIX, data); - for (const message of parseOneTalkMessages(data)) { - pageWindow.console.log( - `[Trade Message Center][OneTalk ${message.messageType} message]`, - message, - ); - } - } catch { - // Logging must never affect the page's WebSocket behavior. - } - }, - ); + observeNewWebSocketMessages( + pageWindow, + (url) => isWebSocketUrlForHost(url, ONETALK_WEBSOCKET_HOST), + (data) => { + try { + pageWindow.console.log(LOG_PREFIX, data); + for (const message of parseOneTalkMessages(data)) { + pageWindow.console.log( + `[Trade Message Center][OneTalk ${message.messageType} message]`, + message, + ); + } + } catch { + // Logging must never affect the page's WebSocket behavior. + } + }, + ); } diff --git a/apps/chrome-extension/src/onetalk/main-page/model.ts b/apps/chrome-extension/src/onetalk/main-page/model.ts index caceb03..0409d8a 100644 --- a/apps/chrome-extension/src/onetalk/main-page/model.ts +++ b/apps/chrome-extension/src/onetalk/main-page/model.ts @@ -3,15 +3,15 @@ export type OneTalkAccountId = string | number; export type OneTalkPageWindow = { - location: Pick; - IcbuIM?: unknown; - __tradeMessageCenterOneTalk?: unknown; + location: Pick; + IcbuIM?: unknown; + __tradeMessageCenterOneTalk?: unknown; }; /** 判断值是否符合 OneTalk 账号标识形状。 */ export function isAccountId(value: unknown): value is OneTalkAccountId { - return ( - (typeof value === "string" && value.length > 0) || - (typeof value === "number" && Number.isFinite(value)) - ); + return ( + (typeof value === "string" && value.length > 0) || + (typeof value === "number" && Number.isFinite(value)) + ); } diff --git a/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts b/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts index a73452e..4da8505 100644 --- a/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts @@ -5,8 +5,8 @@ import { installOneTalkMessageObserver } from "./message-observer/entry.ts"; /** 启动 main-page 目录下的全部 OneTalk 页面能力。 */ function installOneTalkPageFeatures(): void { - installOneTalkMessageObserver(window); - installCurrentConversationHistorySync(window); + installOneTalkMessageObserver(window); + installCurrentConversationHistorySync(window); } installOneTalkPageFeatures(); diff --git a/apps/chrome-extension/src/onetalk/main-page/sync-push-decoder.ts b/apps/chrome-extension/src/onetalk/main-page/sync-push-decoder.ts index 958aef1..b58b49b 100644 --- a/apps/chrome-extension/src/onetalk/main-page/sync-push-decoder.ts +++ b/apps/chrome-extension/src/onetalk/main-page/sync-push-decoder.ts @@ -1,203 +1,203 @@ // 解码 OneTalk 的同步推送数据 export type MessagePackValue = - | null - | boolean - | number - | bigint - | string - | Uint8Array - | MessagePackValue[] - | { [key: string]: MessagePackValue }; + | null + | boolean + | number + | bigint + | string + | Uint8Array + | MessagePackValue[] + | { [key: string]: MessagePackValue }; class MessagePackDecoder { - private readonly bytes: Uint8Array; - private readonly view: DataView; - private readonly textDecoder = new TextDecoder("utf-8", { fatal: true }); - private offset = 0; + private readonly bytes: Uint8Array; + private readonly view: DataView; + private readonly textDecoder = new TextDecoder("utf-8", { fatal: true }); + private offset = 0; - constructor(bytes: Uint8Array) { - this.bytes = bytes; - 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(); + constructor(bytes: Uint8Array) { + this.bytes = bytes; + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); } - return result; - } - - 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"); + get done(): boolean { + return this.offset === this.bytes.byteLength; } - } - /** 解码一个完整的 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)}`); + 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; + } + + 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 值。 */ + 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 同步推送数据。 */ export function decodeOneTalkSyncPushData(encoded: string): MessagePackValue { - const binary = atob(encoded); - const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); - const decoder = new MessagePackDecoder(bytes); - const value = decoder.decode(); + const binary = atob(encoded); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + const decoder = new MessagePackDecoder(bytes); + const value = decoder.decode(); - if (!decoder.done) throw new Error("Unexpected trailing MessagePack bytes"); - return value; + if (!decoder.done) throw new Error("Unexpected trailing MessagePack bytes"); + return value; } diff --git a/apps/chrome-extension/src/onetalk/main-page/utils.ts b/apps/chrome-extension/src/onetalk/main-page/utils.ts index 54600aa..2ca7e37 100644 --- a/apps/chrome-extension/src/onetalk/main-page/utils.ts +++ b/apps/chrome-extension/src/onetalk/main-page/utils.ts @@ -1,5 +1,5 @@ // 提供 main-page 内共享的无副作用辅助判断 export function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); + return typeof value === "object" && value !== null && !Array.isArray(value); } diff --git a/apps/chrome-extension/test/onetalk-current-conversation-history.test.js b/apps/chrome-extension/test/onetalk-current-conversation-history.test.js index 9f09255..9a3e283 100644 --- a/apps/chrome-extension/test/onetalk-current-conversation-history.test.js +++ b/apps/chrome-extension/test/onetalk-current-conversation-history.test.js @@ -9,340 +9,342 @@ import { isAccountId } from "../src/onetalk/main-page/model.ts"; const targetAccountId = 2500002169502; const targetConversation = { - cid: "conversation-1", - accountId: targetAccountId, - accountIdEncrypt: "encrypted-contact-account", - aliId: "seller@icbu", - aliIdEncrypt: "encrypted-seller-account", + cid: "conversation-1", + accountId: targetAccountId, + accountIdEncrypt: "encrypted-contact-account", + aliId: "seller@icbu", + aliIdEncrypt: "encrypted-seller-account", }; function historyMessage(messageId, sendTime) { - return { messageId, sendTime }; + return { messageId, sendTime }; } function createPageWindow({ conversationPages, historyPages }) { - const conversationCalls = []; - const historyCalls = []; - let conversationIndex = 0; - let historyIndex = 0; - let forbiddenCalls = 0; + const conversationCalls = []; + const historyCalls = []; + let conversationIndex = 0; + let historyIndex = 0; + let forbiddenCalls = 0; - const messageService = { - async fetchMessagesWithoutUpdateToRead(options, conversation) { - historyCalls.push({ options, conversation }); - return historyPages[historyIndex++]; - }, - fetchMessages() { - forbiddenCalls += 1; - }, - updateMessageToRead() { - forbiddenCalls += 1; - }, - }; - const sdk = { - getConversationServiceV2() { - return { - async getConversationListByPagination(options) { - conversationCalls.push(options); - return conversationPages[conversationIndex++]; + const messageService = { + async fetchMessagesWithoutUpdateToRead(options, conversation) { + historyCalls.push({ options, conversation }); + return historyPages[historyIndex++]; }, - }; - }, - getMessageService() { - return messageService; - }, - }; - const pageWindow = { - location: { - href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`, - }, - IcbuIM: { - IMBaaSSDK: { default: sdk }, - }, - }; + fetchMessages() { + forbiddenCalls += 1; + }, + updateMessageToRead() { + forbiddenCalls += 1; + }, + }; + const sdk = { + getConversationServiceV2() { + return { + async getConversationListByPagination(options) { + conversationCalls.push(options); + return conversationPages[conversationIndex++]; + }, + }; + }, + getMessageService() { + return messageService; + }, + }; + const pageWindow = { + location: { + href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`, + }, + IcbuIM: { + IMBaaSSDK: { default: sdk }, + }, + }; - return { - pageWindow, - conversationCalls, - historyCalls, - messageService, - forbiddenCalls: () => forbiddenCalls, - }; + return { + pageWindow, + conversationCalls, + historyCalls, + messageService, + forbiddenCalls: () => forbiddenCalls, + }; } test("accepts only finite non-empty OneTalk account identifiers", () => { - for (const value of ["account", 0, -1, Number.MAX_SAFE_INTEGER]) { - assert.equal(isAccountId(value), true); - } + for (const value of ["account", 0, -1, Number.MAX_SAFE_INTEGER]) { + assert.equal(isAccountId(value), true); + } - for (const value of ["", Number.NaN, Number.POSITIVE_INFINITY, null, true]) { - assert.equal(isAccountId(value), false); - } + for (const value of ["", Number.NaN, Number.POSITIVE_INFINITY, null, true]) { + assert.equal(isAccountId(value), false); + } }); test("scans the current conversation and exhausts history through the no-read API", async () => { - const fixture = createPageWindow({ - conversationPages: [ - { - list: [{ ...targetConversation, accountId: "other" }], - hasMore: true, - nextCursor: "page-2", - }, - { list: [targetConversation], hasMore: false }, - ], - historyPages: [ - { - list: [historyMessage(4, 400), historyMessage(3, 300)], - hasMore: true, - }, - { - list: [historyMessage(3, 300), historyMessage(2, 200)], - hasMore: true, - }, - { list: [historyMessage(1, 100)], hasMore: false }, - ], - }); - const sleeps = []; - const randomValues = [0, 1]; + const fixture = createPageWindow({ + conversationPages: [ + { + list: [{ ...targetConversation, accountId: "other" }], + hasMore: true, + nextCursor: "page-2", + }, + { list: [targetConversation], hasMore: false }, + ], + historyPages: [ + { + list: [historyMessage(4, 400), historyMessage(3, 300)], + hasMore: true, + }, + { + list: [historyMessage(3, 300), historyMessage(2, 200)], + hasMore: true, + }, + { list: [historyMessage(1, 100)], hasMore: false }, + ], + }); + const sleeps = []; + const randomValues = [0, 1]; - const result = await syncCurrentConversationHistory(fixture.pageWindow, { - now: () => 500, - random: () => randomValues.shift(), - sleep: async (milliseconds) => sleeps.push(milliseconds), - }); + const result = await syncCurrentConversationHistory(fixture.pageWindow, { + now: () => 500, + random: () => randomValues.shift(), + sleep: async (milliseconds) => sleeps.push(milliseconds), + }); - assert.deepEqual(result, { exit: "history_exhausted", pages: 3, uniqueMessages: 4 }); - assert.deepEqual(fixture.conversationCalls, [ - { cursor: 0, count: 20 }, - { cursor: "page-2", count: 20 }, - ]); - assert.deepEqual( - fixture.historyCalls.map(({ options }) => options), - [ - { - contactAccountId: targetAccountId, - contactAccountIdEncrypt: "encrypted-contact-account", - aliId: "seller@icbu", - aliIdEncrypt: "encrypted-seller-account", - searchMessageId: "", - timeSlide: { forward: false, timeStamp: 500, pageSize: 20 }, - }, - { - contactAccountId: targetAccountId, - contactAccountIdEncrypt: "encrypted-contact-account", - aliId: "seller@icbu", - aliIdEncrypt: "encrypted-seller-account", - searchMessageId: "", - timeSlide: { forward: false, timeStamp: 300, pageSize: 20 }, - }, - { - contactAccountId: targetAccountId, - contactAccountIdEncrypt: "encrypted-contact-account", - aliId: "seller@icbu", - aliIdEncrypt: "encrypted-seller-account", - searchMessageId: "", - timeSlide: { forward: false, timeStamp: 200, pageSize: 20 }, - }, - ], - ); - assert.equal(fixture.historyCalls[0].conversation, targetConversation); - assert.deepEqual(sleeps, [1_000, 3_000]); - assert.equal(fixture.forbiddenCalls(), 0); + assert.deepEqual(result, { exit: "history_exhausted", pages: 3, uniqueMessages: 4 }); + assert.deepEqual(fixture.conversationCalls, [ + { cursor: 0, count: 20 }, + { cursor: "page-2", count: 20 }, + ]); + assert.deepEqual( + fixture.historyCalls.map(({ options }) => options), + [ + { + contactAccountId: targetAccountId, + contactAccountIdEncrypt: "encrypted-contact-account", + aliId: "seller@icbu", + aliIdEncrypt: "encrypted-seller-account", + searchMessageId: "", + timeSlide: { forward: false, timeStamp: 500, pageSize: 20 }, + }, + { + contactAccountId: targetAccountId, + contactAccountIdEncrypt: "encrypted-contact-account", + aliId: "seller@icbu", + aliIdEncrypt: "encrypted-seller-account", + searchMessageId: "", + timeSlide: { forward: false, timeStamp: 300, pageSize: 20 }, + }, + { + contactAccountId: targetAccountId, + contactAccountIdEncrypt: "encrypted-contact-account", + aliId: "seller@icbu", + aliIdEncrypt: "encrypted-seller-account", + searchMessageId: "", + timeSlide: { forward: false, timeStamp: 200, pageSize: 20 }, + }, + ], + ); + assert.equal(fixture.historyCalls[0].conversation, targetConversation); + assert.deepEqual(sleeps, [1_000, 3_000]); + assert.equal(fixture.forbiddenCalls(), 0); }); test("the installed global entry has no dependency override and returns completion", async () => { - const fixture = createPageWindow({ - conversationPages: [{ list: [targetConversation], hasMore: false }], - historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }], - }); + const fixture = createPageWindow({ + conversationPages: [{ list: [targetConversation], hasMore: false }], + historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }], + }); - installCurrentConversationHistorySync(fixture.pageWindow); - const entry = fixture.pageWindow.__tradeMessageCenterOneTalk.syncCurrentConversationHistory; + installCurrentConversationHistorySync(fixture.pageWindow); + const entry = fixture.pageWindow.__tradeMessageCenterOneTalk.syncCurrentConversationHistory; - assert.equal(entry.length, 0); - assert.deepEqual(await entry(), { exit: "history_exhausted", pages: 1, uniqueMessages: 1 }); + assert.equal(entry.length, 0); + assert.deepEqual(await entry(), { exit: "history_exhausted", pages: 1, uniqueMessages: 1 }); }); test("skips unrelated conversation shapes and validates only the active account match", async () => { - const fixture = createPageWindow({ - conversationPages: [ - { - list: [ - { cid: "group-conversation" }, - { accountId: "system-conversation" }, - targetConversation, + const fixture = createPageWindow({ + conversationPages: [ + { + list: [ + { cid: "group-conversation" }, + { accountId: "system-conversation" }, + 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 }), { - exit: "history_exhausted", - pages: 1, - uniqueMessages: 1, - }); - assert.equal(fixture.historyCalls[0].conversation, targetConversation); + assert.deepEqual(await syncCurrentConversationHistory(fixture.pageWindow, { now: () => 500 }), { + exit: "history_exhausted", + pages: 1, + uniqueMessages: 1, + }); + assert.equal(fixture.historyCalls[0].conversation, targetConversation); }); test("converts SDK request failures to a stable secret-free error", async () => { - const fixture = createPageWindow({ - conversationPages: [{ list: [targetConversation], hasMore: false }], - historyPages: [], - }); - fixture.messageService.fetchMessagesWithoutUpdateToRead = async () => { - throw new Error("chatToken=PAGE-SECRET&contactAccountIdEncrypt=BUYER-SECRET"); - }; + const fixture = createPageWindow({ + conversationPages: [{ list: [targetConversation], hasMore: false }], + historyPages: [], + }); + fixture.messageService.fetchMessagesWithoutUpdateToRead = async () => { + throw new Error("chatToken=PAGE-SECRET&contactAccountIdEncrypt=BUYER-SECRET"); + }; - const error = await syncCurrentConversationHistory(fixture.pageWindow).catch((reason) => reason); - assert.equal(error.message, "onetalk_history_message_request_failed"); - assert.equal(error.message.includes("SECRET"), false); + const error = await syncCurrentConversationHistory(fixture.pageWindow).catch( + (reason) => reason, + ); + 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 () => { - let resolveHistory; - const historyResponse = new Promise((resolve) => { - resolveHistory = resolve; - }); - const fixture = createPageWindow({ - conversationPages: [{ list: [targetConversation], hasMore: false }], - historyPages: [historyResponse], - }); - const dependencies = { now: () => 500, sleep: async () => {}, random: () => 0 }; + let resolveHistory; + const historyResponse = new Promise((resolve) => { + resolveHistory = resolve; + }); + const fixture = createPageWindow({ + conversationPages: [{ list: [targetConversation], hasMore: false }], + historyPages: [historyResponse], + }); + const dependencies = { now: () => 500, sleep: async () => {}, random: () => 0 }; - const first = syncCurrentConversationHistory(fixture.pageWindow, dependencies); - const second = syncCurrentConversationHistory(fixture.pageWindow, dependencies); - assert.equal(first, second); + const first = syncCurrentConversationHistory(fixture.pageWindow, dependencies); + const second = syncCurrentConversationHistory(fixture.pageWindow, dependencies); + assert.equal(first, second); - resolveHistory({ list: [historyMessage("m1", 100)], hasMore: false }); - await first; - assert.equal(fixture.historyCalls.length, 1); + resolveHistory({ list: [historyMessage("m1", 100)], hasMore: false }); + await first; + assert.equal(fixture.historyCalls.length, 1); }); test("fails closed when conversation pagination does not advance", async () => { - const fixture = createPageWindow({ - conversationPages: [ - { - list: [{ ...targetConversation, accountId: "other" }], - hasMore: true, - nextCursor: "same", - }, - { - list: [{ ...targetConversation, accountId: "other" }], - hasMore: true, - nextCursor: "same", - }, - ], - historyPages: [], - }); + const fixture = createPageWindow({ + conversationPages: [ + { + list: [{ ...targetConversation, accountId: "other" }], + hasMore: true, + nextCursor: "same", + }, + { + list: [{ ...targetConversation, accountId: "other" }], + hasMore: true, + nextCursor: "same", + }, + ], + historyPages: [], + }); - await assert.rejects( - syncCurrentConversationHistory(fixture.pageWindow), - /onetalk_history_conversation_cursor_stalled/, - ); + await assert.rejects( + syncCurrentConversationHistory(fixture.pageWindow), + /onetalk_history_conversation_cursor_stalled/, + ); }); test("fails closed when history pages cannot make safe progress", async (t) => { - const cases = [ - { name: "empty page", page: { list: [], hasMore: false }, error: "invalid_message_page" }, - { - name: "missing finite sendTime", - page: { list: [{ messageId: 1 }], hasMore: false }, - error: "invalid_message_page", - }, - { - name: "time cursor stalls", - page: { list: [historyMessage("m1", 500)], hasMore: false }, - error: "message_cursor_stalled", - }, - ]; + const cases = [ + { name: "empty page", page: { list: [], hasMore: false }, error: "invalid_message_page" }, + { + name: "missing finite sendTime", + page: { list: [{ messageId: 1 }], hasMore: false }, + error: "invalid_message_page", + }, + { + name: "time cursor stalls", + page: { list: [historyMessage("m1", 500)], hasMore: false }, + error: "message_cursor_stalled", + }, + ]; - for (const scenario of cases) { - await t.test(scenario.name, async () => { - const fixture = createPageWindow({ - conversationPages: [{ list: [targetConversation], hasMore: false }], - historyPages: [scenario.page], - }); - await assert.rejects( - syncCurrentConversationHistory(fixture.pageWindow, { now: () => 500 }), - new RegExp(`onetalk_history_${scenario.error}`), - ); - }); - } + for (const scenario of cases) { + await t.test(scenario.name, async () => { + const fixture = createPageWindow({ + conversationPages: [{ list: [targetConversation], hasMore: false }], + historyPages: [scenario.page], + }); + await assert.rejects( + syncCurrentConversationHistory(fixture.pageWindow, { now: () => 500 }), + new RegExp(`onetalk_history_${scenario.error}`), + ); + }); + } }); test("fails closed when a page contains no new message IDs", async () => { - const fixture = createPageWindow({ - conversationPages: [{ list: [targetConversation], hasMore: false }], - historyPages: [ - { list: [historyMessage(1, 300)], hasMore: true }, - { list: [historyMessage("1", 200)], hasMore: false }, - ], - }); + const fixture = createPageWindow({ + conversationPages: [{ list: [targetConversation], hasMore: false }], + historyPages: [ + { list: [historyMessage(1, 300)], hasMore: true }, + { list: [historyMessage("1", 200)], hasMore: false }, + ], + }); - await assert.rejects( - syncCurrentConversationHistory(fixture.pageWindow, { - now: () => 500, - random: () => 0, - sleep: async () => {}, - }), - /onetalk_history_message_cursor_stalled/, - ); + await assert.rejects( + syncCurrentConversationHistory(fixture.pageWindow, { + now: () => 500, + random: () => 0, + sleep: async () => {}, + }), + /onetalk_history_message_cursor_stalled/, + ); }); test("stops when the active account changes during a history request", async () => { - const fixture = createPageWindow({ - conversationPages: [{ list: [targetConversation], hasMore: false }], - historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }], - }); - const originalFetch = fixture.messageService.fetchMessagesWithoutUpdateToRead; - fixture.messageService.fetchMessagesWithoutUpdateToRead = async (...args) => { - const response = await originalFetch(...args); - fixture.pageWindow.location.href = - "https://onetalk.alibaba.com/?activeAccountId=different-account"; - return response; - }; + const fixture = createPageWindow({ + conversationPages: [{ list: [targetConversation], hasMore: false }], + historyPages: [{ list: [historyMessage("m1", 100)], hasMore: false }], + }); + const originalFetch = fixture.messageService.fetchMessagesWithoutUpdateToRead; + fixture.messageService.fetchMessagesWithoutUpdateToRead = async (...args) => { + const response = await originalFetch(...args); + fixture.pageWindow.location.href = + "https://onetalk.alibaba.com/?activeAccountId=different-account"; + return response; + }; - await assert.rejects( - syncCurrentConversationHistory(fixture.pageWindow), - /onetalk_history_active_account_changed/, - ); + await assert.rejects( + syncCurrentConversationHistory(fixture.pageWindow), + /onetalk_history_active_account_changed/, + ); }); test("fails closed when the required page SDK is unavailable", async () => { - let topLevelGetterCalls = 0; - await assert.rejects( - syncCurrentConversationHistory({ - location: { - href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`, - }, - getConversationServiceV2() { - topLevelGetterCalls += 1; - }, - getMessageService() { - topLevelGetterCalls += 1; - }, - }), - /onetalk_history_sdk_unavailable/, - ); - assert.equal(topLevelGetterCalls, 0); + let topLevelGetterCalls = 0; + await assert.rejects( + syncCurrentConversationHistory({ + location: { + href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`, + }, + getConversationServiceV2() { + topLevelGetterCalls += 1; + }, + getMessageService() { + topLevelGetterCalls += 1; + }, + }), + /onetalk_history_sdk_unavailable/, + ); + assert.equal(topLevelGetterCalls, 0); }); test("fails closed when the matched conversation lacks required identity fields", async () => { - const fixture = createPageWindow({ - conversationPages: [ - { - list: [{ ...targetConversation, cid: undefined }], - hasMore: false, - }, - ], - historyPages: [], - }); + const fixture = createPageWindow({ + conversationPages: [ + { + list: [{ ...targetConversation, cid: undefined }], + hasMore: false, + }, + ], + historyPages: [], + }); - await assert.rejects( - syncCurrentConversationHistory(fixture.pageWindow), - /onetalk_history_invalid_conversation_page/, - ); - assert.equal(fixture.historyCalls.length, 0); + await assert.rejects( + syncCurrentConversationHistory(fixture.pageWindow), + /onetalk_history_invalid_conversation_page/, + ); + assert.equal(fixture.historyCalls.length, 0); }); diff --git a/apps/chrome-extension/test/onetalk-sync-push-decoder.test.js b/apps/chrome-extension/test/onetalk-sync-push-decoder.test.js index 09cd342..f520500 100644 --- a/apps/chrome-extension/test/onetalk-sync-push-decoder.test.js +++ b/apps/chrome-extension/test/onetalk-sync-push-decoder.test.js @@ -6,31 +6,31 @@ import test from "node:test"; import { decodeOneTalkSyncPushData } from "../src/onetalk/main-page/sync-push-decoder.ts"; test("decodes OneTalk status payloads", () => { - const decoded = decodeOneTalkSyncPushData( - "gQGRhAHaACYyMjA4MzE0MDAwNzk4LTI1MDAwMDIxNjk1MDIjMTEwMTFAaWNidQIBAwEEsjI1MDAwMDIxNjk1MDJAaWNidQ==", - ); + const decoded = decodeOneTalkSyncPushData( + "gQGRhAHaACYyMjA4MzE0MDAwNzk4LTI1MDAwMDIxNjk1MDIjMTEwMTFAaWNidQIBAwEEsjI1MDAwMDIxNjk1MDJAaWNidQ==", + ); - assert.deepEqual(decoded, { - 1: [ - { - 1: "2208314000798-2500002169502#11011@icbu", - 2: 1, - 3: 1, - 4: "2500002169502@icbu", - }, - ], - }); + assert.deepEqual(decoded, { + 1: [ + { + 1: "2208314000798-2500002169502#11011@icbu", + 2: 1, + 3: 1, + 4: "2500002169502@icbu", + }, + ], + }); }); test("decodes OneTalk message payloads", () => { - const decoded = decodeOneTalkSyncPushData("ggGs5om+5L2g55yf6Zq+As8AAAGgODt1FA=="); + const decoded = decodeOneTalkSyncPushData("ggGs5om+5L2g55yf6Zq+As8AAAGgODt1FA=="); - assert.deepEqual(decoded, { - 1: "找你真难", - 2: 1787649815828n, - }); + assert.deepEqual(decoded, { + 1: "找你真难", + 2: 1787649815828n, + }); }); test("rejects truncated payloads", () => { - assert.throws(() => decodeOneTalkSyncPushData("2gA="), /Unexpected end/); + assert.throws(() => decodeOneTalkSyncPushData("2gA="), /Unexpected end/); }); diff --git a/apps/chrome-extension/test/onetalk-websocket-tap.test.js b/apps/chrome-extension/test/onetalk-websocket-tap.test.js index b73d991..56bfdb3 100644 --- a/apps/chrome-extension/test/onetalk-websocket-tap.test.js +++ b/apps/chrome-extension/test/onetalk-websocket-tap.test.js @@ -6,188 +6,188 @@ import test from "node:test"; import { installOneTalkMessageObserver } from "../src/onetalk/main-page/message-observer/entry.ts"; class FakeWebSocket extends EventTarget { - static CONNECTING = 0; - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; + static CONNECTING = 0; + static OPEN = 1; + static CLOSING = 2; + static CLOSED = 3; - constructor(url, protocols) { - super(); - this.url = String(url); - this.protocols = protocols; - } + constructor(url, protocols) { + super(); + this.url = String(url); + this.protocols = protocols; + } - receive(data) { - this.dispatchEvent(new MessageEvent("message", { data })); - } + receive(data) { + this.dispatchEvent(new MessageEvent("message", { data })); + } } function testPageWindow() { - const logs = []; - return { - logs, - pageWindow: { - WebSocket: FakeWebSocket, - console: { - log(...args) { - logs.push(args); + const logs = []; + return { + logs, + pageWindow: { + WebSocket: FakeWebSocket, + console: { + log(...args) { + logs.push(args); + }, + }, }, - }, - }, - }; + }; } test("prints raw messages from the OneTalk WebSocket", () => { - const { logs, pageWindow } = testPageWindow(); + const { logs, pageWindow } = testPageWindow(); - installOneTalkMessageObserver(pageWindow); - const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); - const payload = { raw: "unchanged" }; - socket.receive(payload); + installOneTalkMessageObserver(pageWindow); + const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); + const payload = { raw: "unchanged" }; + socket.receive(payload); - assert.ok(socket instanceof FakeWebSocket); - assert.equal(pageWindow.WebSocket.OPEN, FakeWebSocket.OPEN); - assert.equal(logs.length, 1); - assert.equal(logs[0][1], payload); + assert.ok(socket instanceof FakeWebSocket); + assert.equal(pageWindow.WebSocket.OPEN, FakeWebSocket.OPEN); + assert.equal(logs.length, 1); + assert.equal(logs[0][1], payload); }); test("prints plaintext messages from conversation response frames", () => { - const { logs, pageWindow } = testPageWindow(); + const { logs, pageWindow } = testPageWindow(); - installOneTalkMessageObserver(pageWindow); - const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); - const message = { - messageId: "4264696193791.PNM", - cid: "2208314000798-2500002169502#11011@icbu", - createAt: 1787649815828, - content: { text: { content: "找你真难" }, contentType: 1 }, - sender: { uid: "2208314000798@icbu" }, - unreadCount: 0, - }; - const frame = JSON.stringify({ - code: 200, - body: [ - { - singleChatUserConversation: { - lastMessage: { message, readStatus: 2, msgStatus: 1 }, - singleChatConversation: { - pairFirst: "2208314000798@icbu", - pairSecond: "2500002169502@icbu", - }, - }, - }, - ], - }); - socket.receive(frame); + installOneTalkMessageObserver(pageWindow); + const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); + const message = { + messageId: "4264696193791.PNM", + cid: "2208314000798-2500002169502#11011@icbu", + createAt: 1787649815828, + content: { text: { content: "找你真难" }, contentType: 1 }, + sender: { uid: "2208314000798@icbu" }, + unreadCount: 0, + }; + const frame = JSON.stringify({ + code: 200, + body: [ + { + singleChatUserConversation: { + lastMessage: { message, readStatus: 2, msgStatus: 1 }, + singleChatConversation: { + pairFirst: "2208314000798@icbu", + pairSecond: "2500002169502@icbu", + }, + }, + }, + ], + }); + socket.receive(frame); - assert.equal(logs.length, 2); - assert.equal(logs[1][0], "[Trade Message Center][OneTalk new message]"); - assert.equal(logs[0][1], frame); - assert.deepEqual(logs[1][1], { - messageType: "new", - conversationId: "2208314000798-2500002169502#11011@icbu", - messageId: "4264696193791.PNM", - sentAt: 1787649815828, - contentType: 1, - text: "找你真难", - senderId: "2208314000798@icbu", - participantIds: ["2208314000798@icbu", "2500002169502@icbu"], - direction: "received", - readStatus: 2, - messageStatus: 1, - unreadCount: 0, - }); + assert.equal(logs.length, 2); + assert.equal(logs[1][0], "[Trade Message Center][OneTalk new message]"); + assert.equal(logs[0][1], frame); + assert.deepEqual(logs[1][1], { + messageType: "new", + conversationId: "2208314000798-2500002169502#11011@icbu", + messageId: "4264696193791.PNM", + sentAt: 1787649815828, + contentType: 1, + text: "找你真难", + senderId: "2208314000798@icbu", + participantIds: ["2208314000798@icbu", "2500002169502@icbu"], + direction: "received", + readStatus: 2, + messageStatus: 1, + unreadCount: 0, + }); }); test("prints messages from history response frames", () => { - const { logs, pageWindow } = testPageWindow(); + const { logs, pageWindow } = testPageWindow(); - installOneTalkMessageObserver(pageWindow); - const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); - const frame = JSON.stringify({ - code: 200, - body: { - nextCursor: 1787157210197, - hasMore: 1, - userMessageModels: [ - { - readStatus: 2, - msgStatus: 1, - message: { - messageId: "4268023992386.PNM", - cid: "2208314000798-2500002169502#11011@icbu", - createAt: 1787212003008, - content: { text: { content: "我需要深色的" }, contentType: 1 }, - sender: { uid: "2500002169502@icbu" }, - unreadCount: 0, - }, + installOneTalkMessageObserver(pageWindow); + const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); + const frame = JSON.stringify({ + code: 200, + body: { + nextCursor: 1787157210197, + hasMore: 1, + userMessageModels: [ + { + readStatus: 2, + msgStatus: 1, + message: { + messageId: "4268023992386.PNM", + cid: "2208314000798-2500002169502#11011@icbu", + createAt: 1787212003008, + content: { text: { content: "我需要深色的" }, contentType: 1 }, + sender: { uid: "2500002169502@icbu" }, + 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, - 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); + }); + socket.receive(frame); - assert.equal(logs.length, 3); - assert.equal(logs[1][0], "[Trade Message Center][OneTalk history message]"); - assert.deepEqual(logs[1][1], { - messageType: "history", - conversationId: "2208314000798-2500002169502#11011@icbu", - messageId: "4268023992386.PNM", - sentAt: 1787212003008, - contentType: 1, - text: "我需要深色的", - senderId: "2500002169502@icbu", - participantIds: ["2208314000798@icbu", "2500002169502@icbu"], - direction: "sent", - readStatus: 2, - messageStatus: 1, - unreadCount: 0, - }); - assert.equal(logs[2][0], "[Trade Message Center][OneTalk history message]"); - assert.equal(logs[2][1].contentType, 101); - assert.equal(logs[2][1].text, null); + assert.equal(logs.length, 3); + assert.equal(logs[1][0], "[Trade Message Center][OneTalk history message]"); + assert.deepEqual(logs[1][1], { + messageType: "history", + conversationId: "2208314000798-2500002169502#11011@icbu", + messageId: "4268023992386.PNM", + sentAt: 1787212003008, + contentType: 1, + text: "我需要深色的", + senderId: "2500002169502@icbu", + participantIds: ["2208314000798@icbu", "2500002169502@icbu"], + direction: "sent", + readStatus: 2, + messageStatus: 1, + unreadCount: 0, + }); + assert.equal(logs[2][0], "[Trade Message Center][OneTalk history message]"); + assert.equal(logs[2][1].contentType, 101); + assert.equal(logs[2][1].text, null); }); test("does not treat sync push packages as plaintext messages", () => { - const { logs, pageWindow } = testPageWindow(); + const { logs, pageWindow } = testPageWindow(); - installOneTalkMessageObserver(pageWindow); - const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); - socket.receive( - JSON.stringify({ - lwp: "/s/sync", - body: { syncPushPackage: { data: [{ data: "encoded" }] } }, - }), - ); + installOneTalkMessageObserver(pageWindow); + const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/"); + socket.receive( + JSON.stringify({ + lwp: "/s/sync", + body: { syncPushPackage: { data: [{ data: "encoded" }] } }, + }), + ); - assert.equal(logs.length, 1); + assert.equal(logs.length, 1); }); test("ignores other hosts and installs only once", () => { - const { logs, pageWindow } = testPageWindow(); + const { logs, pageWindow } = testPageWindow(); - installOneTalkMessageObserver(pageWindow); - const installedConstructor = pageWindow.WebSocket; - installOneTalkMessageObserver(pageWindow); + installOneTalkMessageObserver(pageWindow); + const installedConstructor = pageWindow.WebSocket; + installOneTalkMessageObserver(pageWindow); - assert.equal(pageWindow.WebSocket, installedConstructor); + assert.equal(pageWindow.WebSocket, installedConstructor); - const unrelated = new pageWindow.WebSocket("wss://example.com/"); - const lookalike = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com.example.com/"); - unrelated.receive("unrelated"); - lookalike.receive("lookalike"); + const unrelated = new pageWindow.WebSocket("wss://example.com/"); + const lookalike = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com.example.com/"); + unrelated.receive("unrelated"); + lookalike.receive("lookalike"); - assert.deepEqual(logs, []); + assert.deepEqual(logs, []); }); diff --git a/apps/chrome-extension/tsconfig.json b/apps/chrome-extension/tsconfig.json index c919a31..814fa63 100644 --- a/apps/chrome-extension/tsconfig.json +++ b/apps/chrome-extension/tsconfig.json @@ -1,12 +1,12 @@ { - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "module": "ESNext", - "moduleResolution": "Bundler", - "allowImportingTsExtensions": true, - "lib": ["ES2022", "DOM"], - "types": [], - "noEmit": true - }, - "include": ["src", "vite.config.ts", "popup"] + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "lib": ["ES2022", "DOM"], + "types": [], + "noEmit": true + }, + "include": ["src", "vite.config.ts", "popup"] } diff --git a/apps/chrome-extension/vite.config.ts b/apps/chrome-extension/vite.config.ts index dd4a6b5..8edf254 100644 --- a/apps/chrome-extension/vite.config.ts +++ b/apps/chrome-extension/vite.config.ts @@ -9,33 +9,34 @@ const configDir = fileURLToPath(new URL(".", import.meta.url)); const buildHash = process.env.BUILD_HASH || randomBytes(8).toString("hex"); export default defineConfig({ - root: configDir, - publicDir: resolve(configDir, "public"), - plugins: [ - { - name: "build-hash", - closeBundle() { - console.info(`Build hash: ${buildHash}`); - }, + root: configDir, + publicDir: resolve(configDir, "public"), + plugins: [ + { + name: "build-hash", + closeBundle() { + 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]", - }, - }, - }, }); diff --git a/apps/server/package.json b/apps/server/package.json index 4c4fa5d..6e42493 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,9 +1,9 @@ { - "name": "@trade-message-center/server", - "private": true, - "version": "0.1.0", - "type": "module", - "dependencies": { - "fastify": "^5.12.1" - } + "name": "@trade-message-center/server", + "version": "0.1.0", + "private": true, + "type": "module", + "dependencies": { + "fastify": "^5.12.1" + } } diff --git a/package.json b/package.json index 00a7d5e..2ad7bcf 100644 --- a/package.json +++ b/package.json @@ -1,31 +1,31 @@ { - "name": "trade-message-center", - "version": "0.1.0", - "private": true, - "workspaces": [ - "apps/*" - ], - "type": "module", - "scripts": { - "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", - "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", - "format": "oxfmt", - "format:check": "oxfmt --check", - "prepare": "husky" - }, - "devDependencies": { - "husky": "^9.1.7", - "lint-staged": "^17.3.0", - "oxfmt": "^0.64.0", - "typescript": "^5.7.3" - }, - "lint-staged": { - "*": "oxfmt --no-error-on-unmatched-pattern" - }, - "engines": { - "node": ">=22.22.2 <23" - }, - "packageManager": "pnpm@11.7.0" + "name": "trade-message-center", + "version": "0.1.0", + "private": true, + "workspaces": [ + "apps/*" + ], + "type": "module", + "scripts": { + "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", + "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", + "format": "oxfmt", + "format:check": "oxfmt --check", + "prepare": "husky" + }, + "devDependencies": { + "husky": "^9.1.7", + "lint-staged": "^17.3.0", + "oxfmt": "^0.64.0", + "typescript": "^5.7.3" + }, + "lint-staged": { + "*": "oxfmt --no-error-on-unmatched-pattern" + }, + "engines": { + "node": ">=22.22.2 <23" + }, + "packageManager": "pnpm@11.7.0" } diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 313f2ec..6d670a6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,4 +1,4 @@ packages: - - apps/* + - apps/* allowBuilds: - esbuild: true + esbuild: true diff --git a/tsconfig.base.json b/tsconfig.base.json index d3eb122..625252e 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -1,12 +1,12 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "strict": true, - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "skipLibCheck": true - } + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "skipLibCheck": true + } }