feat: warn unbound OneTalk accounts

This commit is contained in:
YBF
2026-09-14 18:25:49 +08:00
parent 69bed50f7e
commit b92c25a0e7
19 changed files with 540 additions and 20 deletions
@@ -52,6 +52,10 @@ type OneTalkPageMessage =
| {
type: "onetalk.page.connection-status";
disconnected: boolean;
}
| {
type: "onetalk.page.binding-status";
unbound: boolean;
};
type OneTalkPageHello = {
@@ -105,6 +109,9 @@ Bright -> Service Worker WebSocket -> unique page Port
exact-shape envelope,只允许 `source``version``type``disconnected: boolean` 四个字段。
它不属于 command 或 command-resultMAIN 只调用本地显示回调,不产生结果消息;bridge 不携带账号、binding、错误或 WebSocket 细节。
`onetalk.page.binding-status` 同样是 Service Worker → ISOLATED → MAIN 的 additive v4
exact-shape envelope,只允许 `source``version``type``unbound: boolean` 四个字段。它是按已注册页面账号由 Service Worker 派生的显示投影:无已校验配置、配置账号与页面账号不一致,或当前配置处于稳定 `binding_revoked` 错误时为 true;offline、重连和其它错误为 false。它不属于 command 或 command-result,不能携带 binding、账号、错误码或 Bright 数据;ISOLATED 只经共享 decoder 和方向 guard 原样转发,MAIN 只调用本地显示回调。
### Bridge implementation
\`decodeOneTalkPageMessage(value: unknown)\` 是 MAIN、ISOLATED 和 Service Worker 使用页面消息的唯一运行时入口。消费者必须先解码,再用方向守卫收窄:
@@ -146,7 +153,7 @@ Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN
页面身份变化时,旧页面 command correlation 必须先收敛,再用新身份替换注册;迟到的旧结果不得恢复旧请求。
重复、相同的合法 hello 保持既有页面身份,不得重启 snapshot、buyer delivery、page-ready 或命令路由生命周期;但当该账号的断线 UI latch 仍活跃时,Service Worker 必须只向同账号页面重放 `disconnected: true`。该重放不是 command,也不改变任何页面身份或同步状态。
重复、相同的合法 hello 保持既有页面身份,不得重启 snapshot、buyer delivery、page-ready 或命令路由生命周期;但 Service Worker 必须只向同账号页面重放当前断线 latch 与 `unbound` 显示投影。该重放不是 command,也不改变任何页面身份或同步状态。
Profile envelope 必须显式携带当次读取的 channelAccountId。Service Worker 只接受它与同一 Port 最近一次合法 hello 及当前配置账号完全相同的消息;logout、切账号和旧 Port 消息均 fail closed。
@@ -174,6 +181,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
| Port 名称、sender URL、tab ID 或 frame ID 不合法 | 忽略连接,不加入注册表 |
| envelope、方向或 JSON 结构非法 | 忽略消息 |
| connection-status 缺字段、额外字段或错误方向 | 忽略消息,不调用 MAIN 显示回调 |
| binding-status 缺字段、额外字段、非 boolean 或错误方向 | 忽略消息,不调用 MAIN 显示回调 |
| observation 尚未收到合法 hello | 不调用观察回调 |
| 页面账号无有效 active binding | fail closed |
| onetalk.sync.conversation 的 command/route ID 不同、无同账号页面或同账号多页 | 不投递;分别维持精确路由、waiting_for_page 或 ambiguous_page_route |
@@ -182,7 +190,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
| 页面 hello 身份变化 | 清理旧 pending correlation,待定命令返回 \`delivery_unknown/send_connection_lost\` |
| Service Worker 重新实例化 | 页面注册表为空,等待页面重新连接 |
| Profile envelope 账号与 hello/config 账号不一致 | 丢弃 profile observation,不写 ledger、不发 Bright |
| 同一 page identity 重复 hello | 保持已有 identity,不重复启动 snapshot lifecycle活跃断线 latch 仅重放同账号 UI 状态 |
| 同一 page identity 重复 hello | 保持已有 identity,不重复启动 snapshot lifecycle当前断线/未绑定显示状态仅重放同账号页面 |
| contact snapshot/collect action 缺字段、多字段或字段类型错误 | `rejected_before_send/invalid_request`;不访问 observer、SDK 或页面状态 |
| collect target 不在当前 loaded direct list、为群聊或无登录身份 | `completed/profileCount: 0`;不切换 UI、不扫描历史、不猜测 target |
@@ -190,6 +198,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
- Good:页面先发送合法 helloService Worker 按账号和 command 类型选择唯一 Port;发送目标通过 command payload 传给 MAIN。
- Good:全量 discovery 已在同页缓存会话 B,页面当前选中会话 Aonetalk.sync.conversation(B) 仍投递到该唯一账号页面,并由 MAIN cache 解析 B。
- Good:账号 A 的 page Port 收到 `{ type: "onetalk.page.binding-status", unbound: true }`,账号 B 的 Port 不收到 A 的状态;MAIN 不回传 command-result。
- Goodlive message 指向 direct 会话 B 时,Service Worker 把精确 collect(B) 投递到唯一同账号页面;B 未选中也不会改变路由结果。
- Base:页面 selected 状态变化后重新发送 hello;旧请求不会被新身份的迟到结果完成。
- Bad:将 URL \`conversationId\` 当成 SPA 会话身份、取第一个 selected 节点、广播到所有页面或跨账号回退。
@@ -205,7 +214,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
- 页面 post 失败、Port 断开和身份变化都返回带 reason 的 unknown。
- contact snapshot 和 collect 都覆盖 exact payload rejectioncollect 覆盖非 selected direct target、缺失 target、群聊和缺失页面登录身份。
- 页面桥 build 产物为自包含入口,Manifest 路径、world 和 Port 名称一致。
- connection-status 覆盖 exact-shape、错误 source/version/origin/direction、无 command-result、同账号 Port fan-out 与重复 matching hello 的离线状态重放。
- connection-status 与 binding-status 覆盖 exact-shape、错误 source/version/origin/direction、无 command-result、同账号 Port fan-out 与重复 matching hello 的状态重放binding-status 还覆盖缺配置、账号不匹配、`binding_revoked`、offline/其它错误不触发、配置恢复和 Port 断开后不遗留投递
- raw `contentType``custom.data`、顶层 `text` 或未知 content version 均不能通过 bridgehistory/live 必须复用 MAIN 的同一 decoder。
## 7. Wrong vs Correct
@@ -217,3 +226,11 @@ for (const port of allPagePorts) port.postMessage(command);
// Correct: 断开收敛为 unknown;迟到事实由普通 observation 链路处理。
return { status: "delivery_unknown", reason: "send_connection_lost" };
\`\`\`
\`\`\`ts
// Wrong: 将配置、账号或错误细节越过 bridge 交给 MAIN 再判断未绑定。
port.postMessage({ type: "onetalk.page.binding-status", channelAccountId, error });
// Correct: Service Worker 按页面账号派生并只发送最小显示投影。
runtime.publishBindingStatus(channelAccountId, unbound);
\`\`\`
@@ -22,6 +22,10 @@ const installOneTalkActionStatusTooltip = (pageWindow: Window): void => {};
class ConnectionStatusTooltip {
update(disconnected: boolean): void;
}
class BindingStatusTooltip {
update(unbound: boolean): void;
}
```
入口由 `src/onetalk/main-page/page-script-entry.ts` 安装;复制控件和动作提示的 DOM 实现分别位于 `src/onetalk/main-page/dom/conversation-id-copy.ts``src/onetalk/main-page/dom/action-status-tooltip.ts`。旧根路径仅保留兼容 re-export,不能再次拥有 DOM API。复制控件的 ID 读取复用 `readCurrentConversationId(pageWindow)`;动作提示将 facade 安装到 `window.__tradeMessageCenterOneTalk.tooltip`
@@ -41,7 +45,8 @@ class ConnectionStatusTooltip {
- tooltip facade 或页面 DOM 的异常必须在调用动作提示的本地通知边界内隔离,不能改变 discovery/history 命令结果、重试、消息观察、checkpoint 或持久化。
- `onetalk.sync.conversation``finalAttempt` 仅是页面命令元数据。成功立即结算该会话;失败仅在此标记为 `true` 时结算,第一次可重试失败不得推进显示进度。
- `ConnectionStatusTooltip` 只消费已经由 MAIN page bridge 校验的 `disconnected: boolean`,使用固定 id `onetalk-connection-status``true` 幂等调用 `tooltip.start(..., "连接已断开,正在尝试重新连接", "warning")``false` 只关闭该 id;不得解释 Bright 原始状态或错误。
- 断线行与历史 bootstrap 使用不同 id,保留首次插入顺序、关闭和宿主重挂载语义,彼此不能重排或结算
- `BindingStatusTooltip` 只消费已经由 MAIN page bridge 校验的 `unbound: boolean`,使用固定 id `onetalk-binding-status``true` 幂等调用 `tooltip.start(..., "当前账号未绑定", "error")``false` 只关闭该 id;不得读取配置、页面账号、binding、错误码或 Bright 数据
- 断线、未绑定和历史 bootstrap 使用不同 id,保留首次插入顺序、关闭和宿主重挂载语义,彼此不能重排或结算。未绑定提示只复用 extension-owned 动作提示,不得查询或修改 OneTalk tabs、activeBar、布局或业务 DOM。
## 4. Validation & Error Matrix
@@ -59,13 +64,16 @@ class ConnectionStatusTooltip {
| 传入预设外颜色 | 抛出 `onetalk_action_status_tooltip_color_invalid`,不改变既有行 |
| container 或活动行被宿主移除 | 下次安装、`start``update` 重新按原插入顺序挂载全部活动行 |
| 连接提示为 `true` / `false` | 分别只启动 warning 行 / 关闭固定断线行,不影响历史行 |
| 未绑定提示为 `true` / `false` | 分别只启动 error 行“当前账号未绑定” / 关闭固定未绑定行,不影响断线或历史行 |
## 5. Good / Base / Bad Cases
- Good:用户选中单一会话,按钮和标题同行;点击后仅把该 DOM `data-cid` 写入剪贴板。
- Good`tooltip.start("collect-history", "正在收集所有对话历史", "info")` 与另一个 `id` 纵向共存,且不会改变页面桥或同步流程。
- Good`BindingStatusTooltip.update(true)` 只创建 extension-owned error 行;false 只关闭该行,断线 warning 与历史行保持原有顺序。
- Base:页面首次加载时标题未出现;控件暂不显示,DOM 稳定后再安装。
- Bad:读取地址栏 `conversationId`、复制标题文字或多个 selected 节点中的第一个值。
- Bad:由 `BindingStatusTooltip` 查询 `.im-next-tabs-bar`、创建 activeBar badge,或解释配置、账号、错误码。
- Bad:因为复制失败而创建 bridge frame、写 IndexedDB、上报 Bright 或显示成功状态。
- Bad:让调用方传入 `"#ff0000"` 等任意 CSS 色值,或在 `update` 找不到 `id` 时静默创建新状态行。
@@ -76,6 +84,7 @@ class ConnectionStatusTooltip {
- `test/onetalk-action-status-tooltip.test.js` 覆盖同 `id` 去重、多个 `id` 的纵向插入顺序、`update` / `close` 的 boolean 结果、预设颜色和原始 CSS 色拒绝。
- 动作提示测试必须覆盖容器或行被外部移除后,通过 `update` 或重复安装恢复同一 facade、全部活动行和原始顺序。
- 断线提示测试覆盖与 bootstrap 行并列、重复 `true``false` 关闭及容器重挂载。
- 未绑定提示测试覆盖与断线和 bootstrap 行独立共存、重复 `true` 去重、`false` 仅关闭未绑定行及容器重挂载;源码断言它不查询或创建 OneTalk host DOM。
- 修改控件后执行该定向测试、`pnpm --filter @trade-message-center/chrome-extension typecheck`,以及经仓库根 `scripts/with-build-hash.mjs` 注入构建标识的扩展构建。
## 7. Wrong vs Correct
@@ -99,3 +108,13 @@ tooltip.start("collect-history", "正在收集所有对话历史", "info");
tooltip.update("collect-history", "已收集一半", "warning");
tooltip.close("collect-history");
```
```ts
// Wrong: UI 层重新解释跨层绑定状态,并侵入 OneTalk 宿主布局。
const unbound = readConfig().channelAccountId !== readCurrentPageAccountId();
installBadgeIntoActiveBar(unbound);
// Correct: 只消费 bridge 已校验的最小布尔投影,复用既有 tooltip 生命周期。
const bindingStatus = new BindingStatusTooltip(tooltip);
bindingStatus.update(unbound);
```
@@ -0,0 +1,4 @@
{"file":".trellis/spec/project/architecture.md","reason":"评审状态所有权与跨层职责"}
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"Chrome 扩展质量基线与验证命令"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"验证 tooltip 复用与宿主 DOM 边界"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"验证 exact-shape、方向和账号隔离"}
@@ -0,0 +1,53 @@
# 技术设计:OneTalk 未绑定 tooltip 提醒
## 边界与职责
| 层 | 职责 | 不负责 |
| --- | --- | --- |
| Sync controller | 依据已校验配置、稳定错误状态触发未绑定状态刷新 | 改变配置、授权、重连策略 |
| Page runtime host / runtime | 保存当前配置事实,按 Page Port 账号计算、发布、重放 `unbound` | 解释 DOM、创建 tooltip |
| Page bridge | 唯一 decoder、方向检查和透明转发 | 解释绑定业务含义 |
| MAIN `BindingStatusTooltip` | 将已校验布尔值映射为既有 tooltip 的固定 error 行 | 读取配置、错误码或页面账号 |
## 数据流与契约
```text
已校验配置 + 页面账号 + binding_revoked
-> Sync controller / Page runtime host: unbound:boolean
-> 匹配账号 Page Port
-> ISOLATED 原样转发
-> MAIN exact-shape decoder
-> BindingStatusTooltip
-> action-status-tooltip error row
```
新增页面消息保持 v4、Service Worker → MAIN 方向的 exact shape
```ts
{
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.binding-status",
unbound: boolean,
}
```
任何额外字段、错误类型、非 boolean、错误 source/version/origin 或反向流动都被 decoder/guard 丢弃。消息不得含 `binding`、channelAccountId、错误码、requestId 或 Bright 原始数据。
## 状态规则
- `unbound` 是派生显示状态,而非第二份绑定或配置状态:无通过既有校验的配置、页面账号不等于配置账号、或当前配置收到 `binding_revoked` 时为 `true`;其它错误及 offline 为 `false`
- 状态变化必须 fan-out 到所有匹配账号 Page Port,配置账号变化时旧账号收到 `true` 以清除错误的已绑定视觉,新账号收到其新计算值。无配置时已连接页面在 hello/replay 时得到 `true`
- 重复合法 hello 不改变同步/命令生命周期,但必须重放该页面账号当前 `unbound` 值;端口断开和身份变化不保留旧页面状态。
## MAIN UI 设计
新增 `main-page/binding-status-tooltip.ts`,结构与 `connection-status-tooltip.ts` 对齐:它持有 `OneTalkActionStatusTooltip`,使用固定 id `onetalk-binding-status` 与文案“当前账号未绑定”。`update(true)` 调用 `tooltip.start(id, text, "error")``update(false)` 调用 `tooltip.close(id)`
`page-script-entry.ts` 在既有 tooltip 安装后构造该类,并将已校验的 bridge 回调交给它。不会新增 DOM 模块、属性、CSS、activeBar 选择器、MutationObserver 或页面位置逻辑;tooltip facade 已经拥有行去重、顺序、容器重挂与显示样式。
## 兼容性与回滚
- 黄色 `ConnectionStatusTooltip` 与红色 `BindingStatusTooltip` 使用不同固定 id,因此可同时显示,任何一方关闭不影响另一方或历史进度行。
- 这是只读显示投影失败边界:解析、Port 或 tooltip 显示失败不得影响消息观察、命令、同步、配置或 Bright 连接。
- 回滚只需移除 binding-status 消息变体、账号投递与 `BindingStatusTooltip`;没有迁移、持久化或宿主 DOM 需要恢复。
@@ -0,0 +1,4 @@
{"file":".trellis/spec/project/architecture.md","reason":"项目级职责、状态所有权与契约边界"}
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"Chrome 扩展包基线与验证命令"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-controls.md","reason":"复用 MAIN tooltip 控件的生命周期与测试契约"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"页面桥 exact-shape、方向与账号隔离契约"}
@@ -0,0 +1,41 @@
# 实施计划:OneTalk 未绑定 tooltip 提醒
## 1. 页面状态契约
1.`page-bridge/model.ts` 定义、创建、解码和方向收窄 `onetalk.page.binding-status` exact-shape 消息。
2.`page-bridge/main.ts` 增加独立的显示回调消费者;ISOLATED 仅用共享 decoder 与方向 guard 原样转发。
3. 扩展 bridge 测试:唯一合法 shape 被消费;额外字段、非 boolean、错误 source/version/origin/direction 均拒绝;消息不进入 command-result 流程。
## 2. Service Worker 派生、重放与账号隔离
1.`sync-controller.ts` 的既有状态通知路径调用 host 更新;复用既有配置和错误状态,不复制校验或授权逻辑。
2.`page-runtime-host.ts` 保存最小配置/撤销事实,并在 hello、重复 hello、配置替换、恢复、身份变化和断开时按账号计算/重放显示状态。
3.`runtime.ts` 添加只向匹配账号 Page Port 发布的最小 API;不广播、不建立队列、不重试。
4. 覆盖未配置、账号不匹配、`binding_revoked`、offline/其他错误、恢复清除、重复 hello 和多账号 Port 的回归测试。
## 3. 复用既有 tooltip
1. 新增 `main-page/binding-status-tooltip.ts`,仅将 `unbound` 映射为固定 id 的 error 行生命周期;不新增 CSS 或 DOM 控件。
2.`page-script-entry.ts` 复用已安装的 tooltip facade,连接 bridge 回调与该语义类。
3. 扩展既有 tooltip 测试:红色未绑定行与黄色断线/历史行独立共存、去重、重挂与关闭。
4. 明确断言源码/测试中不创建 activeBar/tabs DOM 提醒、无 `.im-next-tabs-bar` 选择器、无 MutationObserver。
## 4. 验证与评审
1. 审查每层仅拥有自己的状态:Service Worker 派生、bridge 透明转发、MAIN 映射到已有 tooltip。
2. 审查所有 bridge 消息与诊断均不含 binding、账号、错误细节或 Bright 原始数据。
3. 执行定向 Node 测试,随后执行:
```sh
pnpm --filter @trade-message-center/chrome-extension typecheck
node scripts/with-build-hash.mjs --filter @trade-message-center/chrome-extension run build
pnpm --filter @trade-message-center/chrome-extension test
pnpm exec oxfmt --check <changed-files>
git diff --check
```
4. 用户未要求真实 Chrome 验证时不操作其页面、存储或同步;若之后明确要求,可仅以只读 DOM 检查 tooltip 行及 activeBar 无扩展节点。
## 回滚点
本功能没有 migration 或协议服务端变更。若出现显示问题,回滚 binding-status 的页面投影及 `BindingStatusTooltip` 即可;既有 tooltip、黄色断线和同步业务链路保持不变。
@@ -0,0 +1,34 @@
# 复用 OneTalk tooltip 展示未绑定提醒
## Goal
当当前 OneTalk 页面账号不可同步且原因是未绑定时,在既有 extension-owned action-status tooltip 中显示红色“当前账号未绑定”状态行。它必须与现有的黄色断线和历史进度提示使用同一视觉与生命周期体系,不能侵入 OneTalk 原生 tabs/activeBar。
## Confirmed Facts
- 先前实现将独立绝对定位 badge 追加到 `.im-next-tabs-bar`,截图显示它挤入了原生“重要/未读”tabs;用户已回退该实现。
- `main-page/dom/action-status-tooltip.ts` 已提供有状态的 `start``update``close` facade 和受限 `error` 颜色,`ConnectionStatusTooltip` 已是消费页面桥 `disconnected: boolean` 的同类模式。
- 页面桥 v4 已有 Service Worker → ISOLATED → MAIN 的 `onetalk.page.connection-status` exact-shape 投影;它不携带账号、错误码或原始 Bright 数据。
- 未绑定的判定仅限:没有已通过现有校验的配置、配置账号与页面账号不一致,或当前配置收到稳定的 `binding_revoked` 错误。普通离线、重连及其他错误不是未绑定。
## Requirements
- Service Worker 以最小 `{ unbound: boolean }` 投影当前页面账号的未绑定状态;不得向 MAIN、DOM 或日志泄露 binding、账号、错误码或 Bright frame。
- 该状态按账号投递和重放:合法 hello、重复 hello、配置替换、配置恢复、`binding_revoked` 与 Port 断开都不能造成跨账号显示或遗留状态。
- MAIN 仅把已经由页面桥校验的布尔值交给独立的 `BindingStatusTooltip` 语义封装;`true` 启动固定 id 的 `error` 行,`false` 关闭该行。
- 提醒文案固定为“当前账号未绑定”,并与黄色断线提示、历史 bootstrap 提示独立共存、独立关闭和重挂。
- 不新增 `.im-next-tabs-bar` 查询、activeBar DOM、MutationObserver、样式字段或其他 OneTalk 宿主页面 UI 修改。
## Acceptance Criteria
- [ ] 命中未配置、账号不匹配或 `binding_revoked` 时,页面显示既有 tooltip 体系的红色“当前账号未绑定”状态行;普通 offline/重连不会产生该行。
- [ ] 绑定恢复或有效同账号配置替换后,红色行被移除;断线的黄色行和历史进度行不受影响。
- [ ] 页面桥只接受/转发 `onetalk.page.binding-status` 的 exact shape,拒绝额外字段、非 boolean、错误 source/version/origin/direction,且不会产生 command-result。
- [ ] 状态只到达匹配账号的 Page Port;重复 hello 重放当前状态,页面脚本重复安装或 tooltip 容器被移除后仍保持唯一、正确的红色状态行。
- [ ] DOM 中不存在扩展创建的 activeBar/tabs 提醒节点,且不读取页面配置或 binding。
## Out of Scope
- 不改变绑定授权、配置持久化、Bright WebSocket 协议、自动重连或同步业务流程。
- 不添加 Popup、系统通知、其它页面告警或新的通用 tooltip 样式能力。
- 不修改 OneTalk 原生 activeBar/tabs、布局、class 或内容。
@@ -0,0 +1,26 @@
{
"id": "onetalk-unbound-tooltip-warning",
"name": "onetalk-unbound-tooltip-warning",
"title": "复用 OneTalk tooltip 展示未绑定提醒",
"description": "将 OneTalk 当前账号未绑定状态接入既有 action-status tooltip 的 error 行,不创建 activeBar 自定义 DOM 控件。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-14",
"completedAt": null,
"branch": "09-14-onetalk-unbound-tooltip-warning",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,23 @@
// 将已校验的页面绑定状态投影到既有动作提示。
import type { OneTalkActionStatusTooltip } from "./dom/action-status-tooltip.ts";
const BINDING_STATUS_TOOLTIP_ID = "onetalk-binding-status";
const BINDING_UNBOUND_TEXT = "当前账号未绑定";
/** 管理当前页面的未绑定提示行。 */
export class BindingStatusTooltip {
private readonly tooltip: OneTalkActionStatusTooltip;
public constructor(tooltip: OneTalkActionStatusTooltip) {
this.tooltip = tooltip;
}
public update(unbound: boolean): void {
if (unbound) {
this.tooltip.start(BINDING_STATUS_TOOLTIP_ID, BINDING_UNBOUND_TEXT, "error");
return;
}
this.tooltip.close(BINDING_STATUS_TOOLTIP_ID);
}
}
@@ -6,6 +6,7 @@ import {
readOneTalkActionStatusTooltip,
} from "./dom/action-status-tooltip.ts";
import { ConnectionStatusTooltip } from "./connection-status-tooltip.ts";
import { BindingStatusTooltip } from "./binding-status-tooltip.ts";
import { HistoryBootstrapProgressTooltip } from "./current-conversation-history/bootstrap-progress-tooltip.ts";
import { createSendObservationCorrelator } from "./message-observer/send-observation.ts";
import { installOneTalkContactProfileObserver } from "./contact-observer/entry.ts";
@@ -26,6 +27,7 @@ const installOneTalkPageFeatures = (): void => {
if (!tooltip) throw new Error("onetalk_action_status_tooltip_unavailable");
const historyBootstrapProgress = new HistoryBootstrapProgressTooltip(tooltip);
const connectionStatus = new ConnectionStatusTooltip(tooltip);
const bindingStatus = new BindingStatusTooltip(tooltip);
installOneTalkConversationIdCopyControl(window);
const sendObservation = createSendObservationCorrelator();
const profileObserver = installOneTalkContactProfileObserver(
@@ -41,8 +43,12 @@ const installOneTalkPageFeatures = (): void => {
profileObserver,
historyBootstrapProgress,
});
installOneTalkMainPageBridge(window, onCommand, undefined, (disconnected) =>
connectionStatus.update(disconnected),
installOneTalkMainPageBridge(
window,
onCommand,
undefined,
(disconnected) => connectionStatus.update(disconnected),
(unbound) => bindingStatus.update(unbound),
);
installOneTalkCollectionObservers(window, publish, buyerFactSink);
};
@@ -21,6 +21,7 @@ import {
createOneTalkPageHelloMessage,
createOneTalkPageObservedMessage,
decodeOneTalkPageMessage,
isOneTalkPageBindingStatusMessage,
isOneTalkPageCommandMessage,
isOneTalkPageConnectionStatusMessage,
isOneTalkIsolatedToMainMessage,
@@ -40,6 +41,7 @@ export type OneTalkPageCommandHandler = (
) => PageCommandResult | Promise<PageCommandResult>;
export type OneTalkPageConnectionStatusHandler = (disconnected: boolean) => void;
export type OneTalkPageBindingStatusHandler = (unbound: boolean) => void;
export type OneTalkPageHelloRetryOptions = {
retryDelayMs?: number;
@@ -172,6 +174,29 @@ const installConnectionStatusConsumer = (
});
};
const installBindingStatusConsumer = (
pageWindow: OneTalkPageBridgeWindow,
origin: string,
onBindingStatus: OneTalkPageBindingStatusHandler,
isActive: () => boolean,
): void => {
pageWindow.addEventListener("message", (event) => {
try {
if (!isActive() || event.source !== pageWindow || event.origin !== origin) return;
const message = decodeOneTalkPageMessage(event.data);
if (
!message ||
!isOneTalkIsolatedToMainMessage(message) ||
!isOneTalkPageBindingStatusMessage(message)
)
return;
onBindingStatus(message.unbound);
} catch {
// The display-only binding status must not affect MAIN page behavior.
}
});
};
/** 创建把观察批次发布到当前页面 origin 的 MAIN sink。 */
export const createOneTalkPageObservedSink = (
pageWindow: OneTalkPageBridgeWindow,
@@ -260,6 +285,7 @@ export const installOneTalkMainPageBridge = (
onCommand?: OneTalkPageCommandHandler,
retryOptions: OneTalkPageHelloRetryOptions = {},
onConnectionStatus?: OneTalkPageConnectionStatusHandler,
onBindingStatus?: OneTalkPageBindingStatusHandler,
): void => {
try {
const origin = pageOrigin(pageWindow);
@@ -269,6 +295,9 @@ export const installOneTalkMainPageBridge = (
if (onConnectionStatus) {
installConnectionStatusConsumer(pageWindow, origin, onConnectionStatus, () => !stopped);
}
if (onBindingStatus) {
installBindingStatusConsumer(pageWindow, origin, onBindingStatus, () => !stopped);
}
let attempts = 0;
let retryTimer: ReturnType<typeof setTimeout> | null = null;
const retryDelay =
@@ -113,6 +113,13 @@ export type OneTalkPageConnectionStatusMessage = {
disconnected: boolean;
};
export type OneTalkPageBindingStatusMessage = {
source: typeof ONE_TALK_PAGE_BRIDGE_SOURCE;
version: typeof ONE_TALK_PAGE_BRIDGE_VERSION;
type: "onetalk.page.binding-status";
unbound: boolean;
};
export type OneTalkPageMessage =
| OneTalkPageHelloMessage
| OneTalkPageObservedMessage
@@ -120,7 +127,8 @@ export type OneTalkPageMessage =
| OneTalkPageBuyerFactsObservedMessage
| OneTalkPageCommandMessage
| OneTalkPageCommandResultMessage
| OneTalkPageConnectionStatusMessage;
| OneTalkPageConnectionStatusMessage
| OneTalkPageBindingStatusMessage;
export type OneTalkPageBridgeWindow = {
location: Pick<Location, "href" | "origin">;
@@ -552,6 +560,23 @@ const decodeConnectionStatusMessage = (
};
};
const decodeBindingStatusMessage = (
value: Record<string, unknown>,
): OneTalkPageBindingStatusMessage | null => {
if (
!hasExactKeys(value, ["source", "version", "type", "unbound"]) ||
typeof value.unbound !== "boolean"
) {
return null;
}
return {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.binding-status",
unbound: value.unbound,
};
};
/** 从未知输入解码可信的 OneTalk 页面桥消息。 */
export const decodeOneTalkPageMessage = (value: unknown): OneTalkPageMessage | null => {
try {
@@ -574,6 +599,8 @@ export const decodeOneTalkPageMessage = (value: unknown): OneTalkPageMessage | n
return decodeCommandResultMessage(value);
case "onetalk.page.connection-status":
return decodeConnectionStatusMessage(value);
case "onetalk.page.binding-status":
return decodeBindingStatusMessage(value);
default:
return null;
}
@@ -696,6 +723,18 @@ export const createOneTalkPageConnectionStatusMessage = (
};
};
/** 创建只读页面绑定状态消息。 */
export const createOneTalkPageBindingStatusMessage = (
unbound: boolean,
): OneTalkPageBindingStatusMessage => {
return {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.binding-status",
unbound,
};
};
/** 判断消息是否属于页面观察与注册方向。 */
export const isOneTalkPageObservationMessage = (
message: OneTalkPageMessage,
@@ -726,6 +765,13 @@ export const isOneTalkPageConnectionStatusMessage = (
return message.type === "onetalk.page.connection-status";
};
/** 判断消息是否属于只读绑定状态方向。 */
export const isOneTalkPageBindingStatusMessage = (
message: OneTalkPageMessage,
): message is OneTalkPageBindingStatusMessage => {
return message.type === "onetalk.page.binding-status";
};
/** 判断消息是否属于页面命令结果方向。 */
export const isOneTalkPageCommandResultMessage = (
message: OneTalkPageMessage,
@@ -748,6 +794,13 @@ export const isOneTalkMainToIsolatedMessage = (
/** 判断消息是否允许从 ISOLATED 送入 MAIN。 */
export const isOneTalkIsolatedToMainMessage = (
message: OneTalkPageMessage,
): message is OneTalkPageCommandMessage | OneTalkPageConnectionStatusMessage => {
return isOneTalkPageCommandMessage(message) || isOneTalkPageConnectionStatusMessage(message);
): message is
| OneTalkPageCommandMessage
| OneTalkPageConnectionStatusMessage
| OneTalkPageBindingStatusMessage => {
return (
isOneTalkPageCommandMessage(message) ||
isOneTalkPageConnectionStatusMessage(message) ||
isOneTalkPageBindingStatusMessage(message)
);
};
@@ -61,6 +61,8 @@ export class OneTalkPageRuntimeHost {
private connectionEpoch = 0;
private pageEpoch = 0;
private disconnectedChannelAccountId: string | null = null;
private configuredChannelAccountId: string | null = null;
private bindingRevoked = false;
private replayConnectionStatus(channelAccountId: string): void {
if (this.disconnectedChannelAccountId !== channelAccountId) return;
@@ -76,6 +78,18 @@ export class OneTalkPageRuntimeHost {
this.replayConnectionStatus(channelAccountId);
}
private isUnbound(channelAccountId: string): boolean {
return (
this.configuredChannelAccountId === null ||
this.configuredChannelAccountId !== channelAccountId ||
this.bindingRevoked
);
}
private replayBindingStatus(channelAccountId: string): void {
this.runtime.publishBindingStatus(channelAccountId, this.isUnbound(channelAccountId));
}
private configurationEpoch(): number {
return this.options.getConfigurationEpoch?.() ?? 0;
}
@@ -237,6 +251,7 @@ export class OneTalkPageRuntimeHost {
const engine = this.options.getActiveEngine();
const profile = this.options.getActiveProfileCoordinator?.() ?? null;
const buyer = this.options.getActiveBuyerFactCoordinator?.() ?? null;
this.replayBindingStatus(channelAccountId);
if (
this.options.getActiveChannelAccountId &&
this.options.getActiveChannelAccountId() !== channelAccountId
@@ -314,8 +329,10 @@ export class OneTalkPageRuntimeHost {
channelAccountId: string,
conversationId?: string,
): Promise<void> => this.handlePageIdentity(channelAccountId, conversationId),
onRepeatedPageHello: (channelAccountId: string): void =>
this.replayConnectionStatusForActiveAccount(channelAccountId),
onRepeatedPageHello: (channelAccountId: string): void => {
this.replayBindingStatus(channelAccountId);
this.replayConnectionStatusForActiveAccount(channelAccountId);
},
onError: options.onError,
} satisfies OneTalkServiceWorkerRuntimeOptions;
this.runtime = createOneTalkServiceWorkerRuntime(pageRuntimeOptions);
@@ -346,6 +363,21 @@ export class OneTalkPageRuntimeHost {
this.runtime.publishConnectionStatus(previousChannelAccountId, false);
}
public updateBindingStatus(channelAccountId: string | null, bindingRevoked: boolean): void {
if (
this.configuredChannelAccountId === channelAccountId &&
this.bindingRevoked === bindingRevoked
)
return;
const previousChannelAccountId = this.configuredChannelAccountId;
this.configuredChannelAccountId = channelAccountId;
this.bindingRevoked = bindingRevoked;
if (previousChannelAccountId !== null && previousChannelAccountId !== channelAccountId) {
this.runtime.publishBindingStatus(previousChannelAccountId, true);
}
if (channelAccountId !== null) this.replayBindingStatus(channelAccountId);
}
public handleProfileStatus(
profile: OneTalkContactProfileCoordinator,
status: OneTalkBrightConnectionStatus,
@@ -2,6 +2,7 @@
import {
createOneTalkPageCommandMessage,
createOneTalkPageBindingStatusMessage,
createOneTalkPageConnectionStatusMessage,
decodeOneTalkPageMessage,
isOneTalkIsolatedToMainMessage,
@@ -125,6 +126,7 @@ export type OneTalkServiceWorkerRuntime = {
handleConnect: (port: OneTalkServiceWorkerPort) => void;
routePageCommand: (route: OneTalkPageCommandRoute) => Promise<PageCommandResult>;
publishConnectionStatus: (channelAccountId: string, disconnected: boolean) => void;
publishBindingStatus: (channelAccountId: string, unbound: boolean) => void;
};
type PendingPageCommand = {
@@ -766,9 +768,23 @@ export const createOneTalkServiceWorkerRuntime = (
}
};
const publishBindingStatus = (channelAccountId: string, unbound: boolean): void => {
if (!isNonEmptyString(channelAccountId)) return;
const message = createOneTalkPageBindingStatusMessage(unbound);
if (!decodeOneTalkPageMessage(message) || !isOneTalkIsolatedToMainMessage(message)) return;
for (const connection of findAccountPages(connections, channelAccountId)) {
try {
connection.port.postMessage(message);
} catch {
// This read-only display update has no delivery recovery path.
}
}
};
return {
handleConnect,
routePageCommand,
publishConnectionStatus,
publishBindingStatus,
};
};
@@ -120,6 +120,10 @@ export const createOneTalkServiceWorkerSyncController = (
currentSnapshot.connectionStatus,
currentSnapshot.config?.channelAccountId ?? null,
);
pageHost.updateBindingStatus(
currentSnapshot.config?.channelAccountId ?? null,
currentSnapshot.error === "binding_revoked",
);
try {
options.onStatusChange?.(currentSnapshot);
} catch {
@@ -1,7 +1,10 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import test from "node:test";
import { fileURLToPath } from "node:url";
import { installOneTalkActionStatusTooltip } from "../src/onetalk/main-page/dom/action-status-tooltip.ts";
import { ConnectionStatusTooltip } from "../src/onetalk/main-page/connection-status-tooltip.ts";
import { BindingStatusTooltip } from "../src/onetalk/main-page/binding-status-tooltip.ts";
import { HistoryBootstrapProgressTooltip } from "../src/onetalk/main-page/current-conversation-history/bootstrap-progress-tooltip.ts";
class FakeElement {
@@ -70,6 +73,11 @@ const tooltipFor = (pageWindow) => {
return pageWindow.__tradeMessageCenterOneTalk.tooltip;
};
const bindingTooltipSource = readFileSync(
fileURLToPath(new URL("../src/onetalk/main-page/binding-status-tooltip.ts", import.meta.url)),
"utf8",
);
test("starts one neutral status row and does not duplicate its id", () => {
const pageWindow = createPageWindow();
const tooltip = tooltipFor(pageWindow);
@@ -222,3 +230,43 @@ test("keeps the disconnect warning independent from history progress through rem
["onetalk-history-bootstrap"],
);
});
test("keeps the binding error independent from connection and history statuses", () => {
const pageWindow = createPageWindow();
const tooltip = tooltipFor(pageWindow);
const history = new HistoryBootstrapProgressTooltip(tooltip);
const connection = new ConnectionStatusTooltip(tooltip);
const binding = new BindingStatusTooltip(tooltip);
history.beginDiscovery();
connection.update(true);
binding.update(true);
binding.update(true);
let rows = pageWindow.document.body.children[0].children;
assert.deepEqual(
rows.map((row) => row.dataset.tmcActionStatusId),
["onetalk-history-bootstrap", "onetalk-connection-status", "onetalk-binding-status"],
);
assert.equal(rows[2].textContent, "当前账号未绑定");
assert.equal(rows[2].getAttribute("data-tmc-action-status-color"), "error");
pageWindow.document.body.children[0].remove();
binding.update(true);
rows = pageWindow.document.body.children[0].children;
assert.deepEqual(
rows.map((row) => row.dataset.tmcActionStatusId),
["onetalk-history-bootstrap", "onetalk-connection-status", "onetalk-binding-status"],
);
binding.update(false);
rows = pageWindow.document.body.children[0].children;
assert.deepEqual(
rows.map((row) => row.dataset.tmcActionStatusId),
["onetalk-history-bootstrap", "onetalk-connection-status"],
);
});
test("implements binding feedback without OneTalk host DOM controls", () => {
assert.doesNotMatch(bindingTooltipSource, /im-next-tabs-bar|activeBar|MutationObserver/u);
assert.doesNotMatch(bindingTooltipSource, /querySelector|createElement|append/u);
});
@@ -7,6 +7,7 @@ import { createOneTalkBuyerFactFingerprint } from "@trade-message-center/onetalk
import {
createOneTalkPageCommandMessage,
createOneTalkPageCommandResultMessage,
createOneTalkPageBindingStatusMessage,
createOneTalkPageConnectionStatusMessage,
createOneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
@@ -391,14 +392,21 @@ test("forwards only valid current-window MAIN messages once to the named Port",
assert.deepEqual(port.posted, [observed]);
});
test("forwards an exact Service Worker connection status to MAIN without a command result", () => {
test("forwards exact Service Worker display statuses to MAIN without a command result", () => {
const pageWindow = new FakePageWindow();
const port = new FakePort();
const statuses = [];
const bindingStatuses = [];
installOneTalkIsolatedPageBridge(pageWindow, port);
installOneTalkMainPageBridge(pageWindow, undefined, undefined, (disconnected) => {
statuses.push(disconnected);
});
installOneTalkMainPageBridge(
pageWindow,
undefined,
undefined,
(disconnected) => {
statuses.push(disconnected);
},
(unbound) => bindingStatuses.push(unbound),
);
pageWindow.posted.length = 0;
const status = createOneTalkPageConnectionStatusMessage(true);
@@ -423,6 +431,29 @@ test("forwards an exact Service Worker connection status to MAIN without a comma
}
pageWindow.dispatchMessage(status, { origin: "https://evil.example" });
assert.deepEqual(statuses, [true]);
const bindingStatus = createOneTalkPageBindingStatusMessage(true);
assert.equal(isOneTalkMainToIsolatedMessage(bindingStatus), false);
port.dispatchMessage(bindingStatus);
assert.deepEqual(lastPostedMessage(pageWindow), bindingStatus);
pageWindow.dispatchMessage(lastPostedMessage(pageWindow));
assert.deepEqual(bindingStatuses, [true]);
assert.equal(
pageWindow.posted.some((entry) => entry.message.type === "onetalk.page.command-result"),
false,
);
for (const malformed of [
{ ...bindingStatus, unbound: "true" },
{ ...bindingStatus, binding: "secret" },
{ ...bindingStatus, source: "other-source" },
{ ...bindingStatus, version: ONE_TALK_PAGE_BRIDGE_VERSION + 1 },
{ source: bindingStatus.source, version: bindingStatus.version, type: bindingStatus.type },
]) {
port.dispatchMessage(malformed);
}
pageWindow.dispatchMessage(bindingStatus, { origin: "https://evil.example" });
assert.deepEqual(bindingStatuses, [true]);
});
test("keeps the profile envelope identity explicit and rejects sensitive or extra fields", () => {
@@ -280,7 +280,7 @@ test("requests only one profile snapshot for repeated hello from the same page i
const repeatedHello = createOneTalkPageHelloMessage("account-unique", "conversation-unique");
port.dispatchMessage(repeatedHello);
port.dispatchMessage(repeatedHello);
for (const message of port.posted) {
for (const message of port.posted.filter((entry) => entry.type === "onetalk.page.command")) {
port.dispatchMessage(
createOneTalkPageCommandResultMessage(message.requestId, { status: "completed" }),
);
@@ -288,7 +288,9 @@ test("requests only one profile snapshot for repeated hello from the same page i
await Promise.resolve();
assert.deepEqual(
port.posted.map((message) => message.command?.action),
port.posted
.filter((message) => message.type === "onetalk.page.command")
.map((message) => message.command.action),
["onetalk.contact.snapshot"],
);
});
@@ -307,7 +309,9 @@ test("requests a new profile snapshot when the page identity changes", async ()
port.dispatchMessage(createOneTalkPageHelloMessage("account-b"));
assert.deepEqual(
port.posted.map((message) => message.command?.action),
port.posted
.filter((message) => message.type === "onetalk.page.command")
.map((message) => message.command.action),
[],
);
});
@@ -359,7 +363,9 @@ test("routes only live message conversations to targeted profile collection", as
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
port.posted.map((message) => message.command),
port.posted
.filter((message) => message.type === "onetalk.page.command")
.map((message) => message.command),
[{ action: "onetalk.contact.collect", conversationId: "conversation-1" }],
);
});
@@ -160,6 +160,10 @@ class FakePort {
dispatchMessage(message) {
for (const listener of this.messageListeners) listener(message);
}
disconnect() {
for (const listener of this.disconnectListeners) listener();
}
}
const pageSender = {
@@ -357,6 +361,74 @@ test("replays an offline latch when the existing page sends a matching hello aga
);
});
test("projects account-scoped unbound state for missing, mismatched, revoked, and recovered bindings", async () => {
const clients = [];
const controller = createOneTalkServiceWorkerSyncController({
createStore: () => new EmptyStore(),
createBootstrapStore: () => new EmptyBootstrapStore(),
createBrightClient: (options) => {
const client = new FakeBright(options);
clients.push(client);
return client;
},
});
const matchingPage = new FakePort(pageSender);
const otherAccountPage = new FakePort({ ...pageSender, tab: { id: 42 } });
const bindingStatuses = (port) =>
port.posted
.filter((message) => message.type === "onetalk.page.binding-status")
.map((message) => message.unbound);
controller.runtime.handleConnect(matchingPage);
matchingPage.dispatchMessage(createOneTalkPageHelloMessage("account-binding"));
assert.deepEqual(bindingStatuses(matchingPage), [true]);
await controller.configure(config("binding"));
assert.deepEqual(bindingStatuses(matchingPage), [true, false]);
controller.runtime.handleConnect(otherAccountPage);
otherAccountPage.dispatchMessage(createOneTalkPageHelloMessage("account-other"));
assert.deepEqual(bindingStatuses(otherAccountPage), [true]);
clients[0].emitStatus("offline");
assert.deepEqual(bindingStatuses(matchingPage), [true, false]);
clients[0].options.onError(new OneTalkBrightClientError("binding_revoked"));
assert.deepEqual(bindingStatuses(matchingPage), [true, false, true]);
assert.deepEqual(bindingStatuses(otherAccountPage), [true]);
const commandCountBeforeRepeatHello = matchingPage.posted.filter(
(message) => message.type === "onetalk.page.command",
).length;
matchingPage.dispatchMessage(createOneTalkPageHelloMessage("account-binding"));
assert.deepEqual(bindingStatuses(matchingPage), [true, false, true, true]);
assert.equal(
matchingPage.posted.filter((message) => message.type === "onetalk.page.command").length,
commandCountBeforeRepeatHello,
);
await controller.configure({ ...config("binding"), binding: "binding-recovered" });
assert.deepEqual(bindingStatuses(matchingPage), [true, false, true, true, true, false]);
const disconnectedPage = new FakePort({ ...pageSender, tab: { id: 43 } });
controller.runtime.handleConnect(disconnectedPage);
disconnectedPage.dispatchMessage(createOneTalkPageHelloMessage("account-binding"));
assert.deepEqual(bindingStatuses(disconnectedPage), [false]);
disconnectedPage.disconnect();
await controller.configure(null);
assert.deepEqual(bindingStatuses(matchingPage), [
true,
false,
true,
true,
true,
false,
false,
true,
]);
assert.deepEqual(bindingStatuses(disconnectedPage), [false]);
});
test("replay only resumes durable buyer delivery after a page hello", async () => {
let activeEngine = null;
let activeBuyer = null;
@@ -454,7 +526,9 @@ test("standalone sync runtime requests the initial snapshot when Bright authenti
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
port.posted.map((message) => message.command?.action),
port.posted
.filter((message) => message.type === "onetalk.page.command")
.map((message) => message.command.action),
["onetalk.contact.snapshot"],
);
runtime.dispose();