fix: add scoped rendered-card ledger readers

This commit is contained in:
YBF
2026-09-15 14:44:41 +08:00
parent 330b40b0dd
commit c19e709c93
21 changed files with 1623 additions and 308 deletions
@@ -84,7 +84,7 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
### Durable lifecycle
- `ONE_TALK_SYNC_DATABASE_VERSION``10`。独立的 `onetalk_contact_profiles` store 以账号/会话对为键;消息、candidate、checkpoint、anomaly 各 store 保持独立。v8→v9 只建立查询 index 并保留 ledger recordv9→v10 只在统一主 schema 新增 `onetalk_buyer_facts`,保留 profile 与所有既有 OneTalk ledger records/indexes`hasProfileRecord(account)` 走账号 index countpending flush 以 `[channelAccountId, pending.observedAtMs]` 的合法账号前缀范围读取。无 `pending.observedAtMs` 的 uploaded/rejected record 不得出现在该 index 结果中,也不得回退到 object-store 全表扫描。
- `ONE_TALK_SYNC_DATABASE_VERSION``10`。独立的 `onetalk_contact_profiles` store 以账号/会话对为键;消息、candidate、checkpoint、anomaly 各 store 保持独立。v8→v9 只建立既有消息/profile 的 scope query indexes 并保留 ledger recordsv9→v10 只在统一主 schema 新增 `onetalk_buyer_facts` store,并为 `onetalk_rendered_card_ledger` 建立 `[channelAccountId, status]``[channelAccountId, conversationId]` indexes,保留 profile、rendered-card 与所有既有 OneTalk ledger records/indexes,不重写既有记录。`hasProfileRecord(account)` 走账号 index countpending flush 以 `[channelAccountId, pending.observedAtMs]` 的合法账号前缀范围读取。无 `pending.observedAtMs` 的 uploaded/rejected record 不得出现在该 index 结果中,也不得回退到 object-store 全表扫描。
- durable profile 指纹是上传去重的唯一闸门。每收到一条 profile,先在 `putPendingProfile()` 之前读取它的 `[channelAccountId, conversationId]` 记录。若 `pending.fingerprint``lastUploadedFingerprint` 与来入指纹相同,读后即结束:不更新时间戳/高水位、不写 IndexedDB、不发 `profile_observed` 诊断、不 flush、不发送。指纹不同时走既有的 durable-first pending 写入。
- 同键观察对 `getProfile → putPendingProfile` 转换做串行化。并发的相同指纹可以各自执行读取,但最多一个能写入、诊断或 flush。读取失败对调用方保持可见;tracker 清理不得制造第二个未处理 rejection。durable 读进行期间协调器被 dispose 时,其完成不产生写入、诊断或 flush。
- 只有 readwrite transaction 完成且当前 pending 的 fingerprint 与 `observedAtMs` 仍然匹配时,ACK 才标记 uploaded。更旧、未知、重复或不匹配的 ACK 一律 no-op。future-skew 错误只移除精确匹配的 pending snapshot,并记录其被拒观察水位。
@@ -123,7 +123,7 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
- Contract:精确 profile/frame 字段、协议版本、方向/scope、direct discovery 类型、敏感/未知字段拒绝、空/超限批次和 `256 KiB` 字节上限。
- Observerinitial snapshot、可重复的 `syncData` 发布、精确 snapshot/collect payload 校验、targeted direct 查找、群聊/未知目标排除、登录身份、登出/切账号、CRM 客户匹配和头像 URL 校验。
- Ledgerpre-v7`oldVersion < 7`)升级先清空全部旧 OneTalk store 再重建当前 profile 与消息同步状态,v7→v8 升级保留五个既有 storev8→v9 逐条保留六个 OneTalk store record 并创建 scope indexv9→v10 保留所有既有 OneTalk ledger records/indexes新增 buyer facts store;账号/会话键、durable-first 顺序、同 pending/已上传指纹一次读零写、同键并发写、读中 dispose、显式读取失败、ACK/CAS、future-skew 丢弃、重连与重启恢复。测试必须区分 profile account/pending index 与 `objectStore.getAll()`,并确认 uploaded record 不会混入 pending flush。升级必须保留 configuration/deviceId,且不得 rekey 或重试旧 pending ledger。
- Ledgerpre-v7`oldVersion < 7`)升级先清空全部旧 OneTalk store 再重建当前 profile 与消息同步状态,v7→v8 升级保留五个既有 storev8→v9 逐条保留六个 OneTalk store record 并创建 scope indexv9→v10 逐条保留所有既有 OneTalk ledger records/indexes新增 buyer facts store,并为 rendered-card ledger 新增 account/status 与 account/conversation indexes;账号/会话键、durable-first 顺序、同 pending/已上传指纹一次读零写、同键并发写、读中 dispose、显式读取失败、ACK/CAS、future-skew 丢弃、重连与重启恢复。测试必须区分 profile account/pending、rendered-card scope indexes`objectStore.getAll()`,并确认 uploaded profile record 不会混入 pending flush、会话重建不会遗漏对应 rendered-card record。升级必须保留 configuration/deviceId,且不得 rekey 或重试旧 pending ledger。
- Service Worker:既有 Bright binding/read/sync 授权、page-first 与 auth-first 两种顺序的首次认证 setup、ledger 读取后授权丢失、实时 sent/received targeted collect、历史排除、50/51 条 profile 批次、ACK 计数、future 错误映射和过期回调/页面身份 fencedirect discovery 始终携带 `conversationType: "direct"`
- 必须执行定向 typecheck、contract/extension focused 与全量测试、format check 和 `git diff --check`。真实 Chromium、Bright PostgreSQL 和生产 Mind 集成是另行执行的外部检查。
@@ -61,17 +61,18 @@ rejected
### Profile ledger (independent state machine)
联系人资料不使用消息 candidate/checkpoint/anomaly store。`ONE_TALK_SYNC_DATABASE_VERSION=10`:从 `oldVersion < 7` 升级时,transaction 必须先清空五个 OneTalk store(消息、candidate、checkpoint、anomaly、`onetalk_contact_profiles`),不重键或回补旧 ledger;从 v7 升级到 v8 时只新增 `onetalk_conversation_bootstraps` store,必须保留五个既有 store 的记录;从 v7/v8 升级到 v9 时只为既有消息、checkpoint、candidate、anomaly、profile records 建立账号、会话与状态查询索引,六个 store 的记录逐条保留;从 v9 升级到 v10 只在统一 `trade-message-center` schema 新增 `onetalk_buyer_facts` store必须保留所有既有 OneTalk stores、records 与 indexes。数据库名、版本与 upgrade handler 只由 `storage.ts` 拥有;buyer ledger 通过该主库路径打开,不得创建独立数据库。legacy clear 完成后才建 index,避免为必然删除的 records 建索引。Chrome 配置、deviceId、binding 与其它渠道存储不属于该删除范围;首次 pre-v7 升级后必须重新采集 profile 并从 clean state 执行 full syncv7→v10 不得因为 schema 演进清理既有事实。v7 checkpoint 新增 OneTalk 会话列表来源的 `latestMessageAtMs`,用于同步完成后更新服务端会话活动时间。
联系人资料不使用消息 candidate/checkpoint/anomaly store。`ONE_TALK_SYNC_DATABASE_VERSION=10`:从 `oldVersion < 7` 升级时,transaction 必须先清空五个 OneTalk store(消息、candidate、checkpoint、anomaly、`onetalk_contact_profiles`),不重键或回补旧 ledger;从 v7 升级到 v8 时只新增 `onetalk_conversation_bootstraps` store,必须保留五个既有 store 的记录;从 v7/v8 升级到 v9 时只为既有消息、checkpoint、candidate、anomaly、profile records 建立账号、会话与状态查询索引,六个 store 的记录逐条保留;v9→v10 只在统一 `trade-message-center` schema 新增 `onetalk_buyer_facts` store并为 `onetalk_rendered_card_ledger` 新增受限查询 indexes,逐条保留所有既有 stores、records 与 indexes。数据库名、版本与 upgrade handler 只由 `storage.ts` 拥有;buyer ledger 通过该主库路径打开,不得创建独立数据库。legacy clear 完成后才建 index,避免为必然删除的 records 建索引。Chrome 配置、deviceId、binding 与其它渠道存储不属于该删除范围;首次 pre-v7 升级后必须重新采集 profile 并从 clean state 执行 full syncv7→v8/v9/v10 不得因为 schema 演进清理既有事实。v7 checkpoint 新增 OneTalk 会话列表来源的 `latestMessageAtMs`,用于同步完成后更新服务端会话活动时间。
清空后的 `onetalk_contact_profiles` 业务键为 `channelAccountId + conversationId`;记录包含 key、账号、conversationId、资料字段中的 aliId、lastUploadedFingerprint、uploaded/rejected observed high-water mark、updatedAt、lastUploadedAt 和最新 pending 清洗 profile。资料上传到 Bright persistence,不调用 Mind profile HTTP。
### v9 scope-bounded reads
### Scope-bounded reads and the v10 rendered-card ledger
- `checkpoint` 使用 `by_account`candidate 使用 `by_account_status``by_account_conversation``by_account_conversation_status`message/anomaly 使用 `by_account_conversation`profile 使用 `by_account``by_account_pending_observed_at`。bootstrap 继续只按稳定主键读取,不为无消费者的查询建 index。
- rendered-card ledger 使用 `by_account_status` 读取精确 `[channelAccountId, "pending_ack"]`,使用 `by_account_conversation` 在会话重建 transaction 中删除精确 `[channelAccountId, conversationId]` records;这两种读取均不得恢复 `objectStore.getAll()` 或内存过滤其它账号。
- 已知 ACK `candidateKey` 必须直接主键读取,且只接纳仍为 `pending_ack` 的 candidate;没有 request key 时才兼容查找 `(channelAccountId, conversationId, messageId)`。确认后只比较 checkpoint 当前 `latestMessageId` candidate 与本次 confirmed candidate 的 `candidateOrder`,不得重建全会话候选列表。
- profile pending 读取必须使用 `[channelAccountId, pending.observedAtMs]` 的合法账号前缀 `IDBKeyRange`;缺少 `pending.observedAtMs` 的 uploaded/rejected record 没有该 index entry,不得由调用方再过滤整表。
- 会话重建必须在一个 readwrite transaction 中,对目标 scope index 执行 `getAllKeys()` 并删除 message/candidate/anomaly,再直接删除该会话 checkpoint;不得在 transaction 外读后删,也不得使用 object-store `getAll()`
- v10 schema 一旦打开,v9 binary 打开同一数据库会因版本较低失败。发布回滚只能通过后续前向迁移,或保留 v10 schema 并仅禁用非 schema 行为;不得重发 v9 或清除 durable ledger。
- 会话重建必须在一个 readwrite transaction 中,对目标 scope index 执行 `getAllKeys()` 并删除 message/candidate/anomaly/rendered-card record,再直接删除该会话 checkpoint;不得在 transaction 外读后删,也不得使用 object-store `getAll()`
- v10 database 一旦打开,v9 binary 打开同一数据库会因版本较低失败。发布回滚只能通过 v11 前向迁移,或保留 v10 schema 并仅禁用非 schema 行为;不得重发 v9 或清除 durable ledger。
Observation 先写最新 pending,再由现有 Bright WebSocket 发送 contact.profile.observed。同 fingerprint 且无 pending 时只推进更高 observed high-water mark;任何不高于 uploaded/rejected high-water mark 的不同 fingerprint 也跳过。断线、Service Worker 重启或新页面连接只从 pending 重建发送。收到 contact.profile.ack 后,必须等待 readwrite transaction oncomplete,且只确认仍匹配的 fingerprint 和 observedAtMs;迟到旧 ACK 不得删除新 pending。收到 `ws.error` `profile_observed_at_future` 时只丢弃该 request 的精确 pending,避免无限重试。
@@ -179,7 +180,70 @@ Service Worker 重启后必须从 IndexedDB 恢复:
- 断线恢复会重新发现会话并恢复未确认事实和 completion 声明。
- \`delivery_unknown\` 不创建发送任务、不自动重发;迟到消息继续进入普通 observation。
- 页面同步路由、Bright ACK 和 IndexedDB 事务顺序在重启/断线下保持一致。
- `oldVersion < 7` 升级到 v10 必须断言五个 OneTalk store 均为空、没有 profile 重键/重试分支,且下一次 bootstrap 重新采集 profile、会话活动时间并执行 full sync;v7→v8 必须断言新增 bootstrap store 且五个既有 store 逐条保留;v8→v9 必须断言六个 OneTalk store 的 records 逐条保留、目标 index 存在且读取不调用 `objectStore.getAll()`;v9→v10 必须断言所有既有 OneTalk records/indexes 逐条保留新增 `onetalk_buyer_facts`并验证 buyer ledger 以主数据库名与 v10 通过同一 upgrade handler 打开;配置、deviceId 与其它渠道数据在所有升级路径保持不变。
- `oldVersion < 7` 升级到 v10 必须断言五个 OneTalk store 均为空、没有 profile 重键/重试分支,且下一次 bootstrap 重新采集 profile、会话活动时间并执行 full sync;v7→v8 必须断言新增 bootstrap store 且五个既有 store 逐条保留;v8→v9 必须断言六个 OneTalk store 的 records 逐条保留、目标 index 存在且读取不调用 `objectStore.getAll()`;v9→v10 必须断言所有既有 OneTalk records/indexes 逐条保留新增 `onetalk_buyer_facts`rendered-card records 保留且 `by_account_status``by_account_conversation` 同时存在,pending read 只返回目标账号的 `pending_ack`history cleanup 只删除目标账号/会话的 rendered-card record;配置、deviceId 与其它渠道数据在所有升级路径保持不变。
## Scenario: v10 rendered-card ledger scoped indexes
### 1. Scope / Trigger
当 rendered-card pending recovery、会话重建或 `ONE_TALK_SYNC_DATABASE_VERSION` 发生变化时,必须按此场景实现 v10。它修复索引重构遗漏,不得改变 ledger 的 durable-first、first-content-wins、ACK 或 conflict 规则。
### 2. Signatures
```ts
const ONE_TALK_SYNC_DATABASE_VERSION = 10;
type OneTalkRenderedCardLedgerStore = {
listPending: (channelAccountId: string) => Promise<OneTalkRenderedCardLedgerRecord[]>;
};
```
`onetalk_rendered_card_ledger` 的 v10 indexes 是 `by_account_status: [channelAccountId, status]``by_account_conversation: [channelAccountId, conversationId]`
### 3. Contracts
- v9→v10 只创建上述两个 index;不能清库、重键、回填或改写既有 record。
- `listPending(channelAccountId)` 只能通过 `by_account_status` 查询 `[channelAccountId, "pending_ack"]`
- `clearConversationHistory(channelAccountId, conversationId)` 在既有 readwrite transaction 中,以 `by_account_conversation.getAllKeys([channelAccountId, conversationId])` 删除 rendered-card record。
- `readAll``objectStore.getAll()` 和跨账号内存过滤都不是 rendered-card pending/rebuild 的兼容 fallback。
### 4. Validation & Error Matrix
| 条件 | 必须行为 |
| --- | --- |
| v9 数据库含 rendered-card record | 升级后 record 保留,两个 v10 index 存在 |
| `listPending("account-a")` | 仅返回 account-a 的 `pending_ack` record |
| 同账号 confirmed/rejected record | pending read 不返回 |
| history rebuild 清理 account-a/conversation-1 | 只删除该 scope 的 rendered-card record |
| 缺 index、全表读取或未带 account 的 pending read | 显式失败或测试失败;不得扫描其它账号 |
### 5. Good / Base / Bad Cases
- Goodv9 record 不变地升级到 v10pending upload 只读取当前账号。
- Base:同一账号的其它会话与其它账号的同名会话均保留。
- Bad:为恢复编译而重新引入 `readAll`,或重建时遗漏 rendered-card ledger。
### 6. Tests Required
- storage migration 覆盖 v9 record 保留和两个 index 存在。
- pending read 覆盖两个账号、`pending_ack` 与 terminal status,并断言没有 object-store `getAll()`
- history cleanup 覆盖目标会话、同账号另一会话与另一账号同名会话。
- 运行 storage/coordinator focused tests、extension typecheck、format 和 diff check。
### 7. Wrong vs Correct
```ts
// Wrong: the removed helper scans every account and the rebuild loop omits cards.
const pending = await readAll(database, ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME);
```
```ts
// Correct: both recovery and rebuild use an exact durable scope index.
await readByIndex(database, ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME, "by_account_status", [
channelAccountId,
"pending_ack",
]);
```
## 7. Wrong vs Correct
@@ -17,7 +17,7 @@ OneTalk 插件同时需要以下能力时,遵循本总览和对应子规范:
| --- | --- |
| [OneTalk 页面桥、Port 与命令路由](./page-bridge.md) | MAIN/ISOLATED/SW 页面桥、Port 注册、页面身份和 command 路由 |
| [OneTalk 耐久同步与连接生命周期](./durable-sync.md) | IndexedDB、full/incremental/live、ACK、checkpoint、重启恢复和连接生命周期 |
| [OneTalk 联系人资料 Bright 持久化](./contact-profile-sync.md) | profile 白名单、Bright profile frame、pre-v7 清空后重采集、v9 scope index、v10 统一 buyer store、ACK/HWM/future-skew 和账号/epoch 隔离 |
| [OneTalk 联系人资料 Bright 持久化](./contact-profile-sync.md) | profile 白名单、Bright profile frame、pre-v7 清空后重采集、v9 profile scope indexes、v10 rendered-card scope indexes 与统一 buyer facts store、ACK/HWM/future-skew 和账号/epoch 隔离 |
| [OneTalk 扩展安装实例设备身份](./device-identity.md) | deviceId 生成、迁移、独立存储、配置清除和生命周期 |
| [OneTalk Service Worker 状态与诊断](./runtime-diagnostics.md) | getSnapshot、错误投影、敏感信息脱敏和 development 构建 |
| [OneTalk PWA 出站发送 SOP](./send-sop.md) | sendUIMessages 输入、SDK-only 发送和 WebSocket 旁路事实确认 |
@@ -36,7 +36,7 @@ OneTalk MAIN world
-> Bright WebSocket upload
-> per-message ACK
联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker pre-v7 clean-state / v9 scope-indexed profile ledger(位于 v10 统一 schema)→ existing Bright plugin WebSocket `contact.profile.observed` → Bright guarded profile transaction → `contact.profile.ack`
联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker pre-v7 clean-state / v9 scope-indexed profile ledger and v10 rendered-card scope-indexed ledger(位于统一 schema,另含 buyer facts store)→ existing Bright plugin WebSocket `contact.profile.observed` → Bright guarded profile transaction → `contact.profile.ack`
\`\`\`
服务端命令:
@@ -247,7 +247,7 @@ writer.sendSendConfirmation(result);
- `authorization_unavailable` 表示授权依赖暂时不可用,只关闭当前 Bright socket 并沿既有连接退避自动重连;只有凭证、授权版本、binding、scope 或协议版本等确定性错误才阻断自动重连并进入 unauthorized。
- Service Worker 重启从 IndexedDB 恢复 checkpoint、候选和模式,不信任旧内存 cursor。
- live `messageType: "new"`sent 或 received)只触发所属 conversation 的 profile collecthistory 不触发。profile 相同 fingerprint 在 coordinator 的唯一 durable read 后静默结束,不能通过 MAIN `seen`、时间水位或 timer 再建第二去重状态。
- `oldVersion < 7` 的数据库先执行全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实;v8→v9 只新增 scope index、逐条保留 recordsv9→v10 只在统一主 schema 新增 buyer facts store保留既有 stores、recordsindexes),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。v9 profile pending flush 只读取 `[channelAccountId, pending.observedAtMs]` index,不得扫描其它账号或已上传 record。ACK 只在当前 `[channelAccountId, conversationId, fingerprint, observedAtMs]` 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。
- `oldVersion < 7` 的数据库先执行全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实;v8→v9 只新增 profile scope index、逐条保留 recordsv9→v10 只新增 rendered-card scope indexes 与 buyer facts store、逐条保留既有 records/indexes),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。v9 profile pending flush 只读取 `[channelAccountId, pending.observedAtMs]` indexv10 rendered-card pending flush 只读取 `[channelAccountId, "pending_ack"]`,两者都不得扫描其它账号或 terminal record。ACK 只在当前 `[channelAccountId, conversationId, fingerprint, observedAtMs]` 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。
### Send and protocol boundaries
@@ -0,0 +1,3 @@
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Verify the patch keeps MAIN, ISOLATED, Service Worker, and Bright ownership separated."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Verify the observer change preserves durable-first and base-candidate-confirmed delivery semantics."}
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Verify storeImageUrl removal changes exact snapshots intentionally without weakening duplicate or conflict behavior."}
@@ -0,0 +1,54 @@
# 统一 OneTalk React 卡片 reader 设计
## Scope and ownership
本任务是一个共享实现单元,不拆 child task:订单、询盘、商品都由同一个 MAIN observer、同一 identity/boundary rule 与同一 rendered-card union 承载;拆分会让 strict classifier、shared product projection 和 contract shape 在不同任务内失配。
仅修改 contract 的 rendered-product shape、Chrome extension 的 MAIN card reader/test,以及恢复 rendered-card ledger 已承诺的 scope index 查询。既有 page bridge、ISOLATED forwarder、Service Worker coordinator、ledger 状态机、Bright flow、server supplement repository 和 Mind publisher 不改行为。
## Data flow
```text
mounted .message-item-wrapper
-> common bounded Fiber identity reader
-> strict card classifier
-> order | inquiry | product typed projection
-> exact OneTalkRenderedCardContent validation
-> existing page bridge / ledger / Bright / server supplement
```
MutationObserver 只发现挂载;Fiber `itemData` 继续提供 message identity、conversation cross-check、direction 与 sentAt evidence。模板 `props.data` 只能由各 reader 的直接 predicate 选中,不能递归复制对象。每次读取前后均要求唯一 selected conversation 未变化。
## Strict classifiers and projections
| Kind | Required identity/classifier | Template predicate and projection |
| --- | --- | --- |
| order | `msgType=10010`, `originalData.cardType=9` | `data.cardTitle`、商品列表、`orderStatusText``shippingAddress`;列表项 title allowlist 增加真实 `productName`image allowlist 保持 `productImage` 等现有路径。 |
| inquiry | `msgType=10010`, `originalData.cardType=6` | `data.inquiryCardDTO``displayProducts/products``inquiryContent``inquiryID`;首个 displayed product 映射 name/image/MOQ/unit;动作按 `detailStr/detailAction``quoteStr/quoteAction``actionName/inquiryAction` 的固定顺序产生 `{ label, available }`,不穿透动作对象。 |
| product | `msgType=101` 且同一有界 Fiber 子树存在 renderer `cardType=54` | `data.title``productImage``price``moq``moqUnit``productAction``productAction.actionParams.url` 经 canonical product URL normalizer 得到 product reference;固定顺序收集非空 `benefitFirstText``benefitSecondText``promotionTag` 为 badges。 |
只有上述三种分类可产生 observation。文件 `cardType=12`、未知 cardType、缺失 template、非 `cardType=54``msgType=101`、身份或会话不一致均返回 `null`,不会写 ledger。
## Shared contract changes
-`OneTalkRenderedProductContent` 删除 `storeImageUrl`,并从精确键校验、clone/canonical fingerprint 的输入 shape、所有 fixture 与断言中去除。当前仓库不存在 rendered-product producer 或 stored fixture,故不需要 JSONB migration/backfill;新的 strict decoder 不接受该旧键。
- 把 product URL canonicalization/validation 提取为不依赖 `content.ts``rendered-cards.ts` 的 contract-owned module。避免 `content.ts` 已依赖 rendered-card union 时产生循环依赖。
- 基础消息 `kind: "product"` 与 rendered-card product reader 共用该 normalizerrendered-card validator 要求 `sourceUrl` 是 canonical product URL 且从路径导出的 product ID 与 payload `productId` 精确相等,不能复用图片 URL validator。
- `product.imageUrl` 仍单独使用 card image URL validator。商品没有独立店铺图的运行时证据,contract 中不保留任何替代字段。
## Safety and compatibility
- 不桥接 raw `content``originalData``inquiryCardDTO`、action params、callback、trace、hidden ID、URL query/fragment/credential、DOM/Fiber reference。
- 既有 rendered-card ledger 的 first-content-wins、fingerprint、ACK 与 conflict 语义不变;contract 收窄后 fingerprint 自然只基于允许字段。
- 当前协议的唯一运行 producer 是订单 reader;当前 source tree 中没有 rendered-product producer。因此删除未生产字段不需要服务器 migration 或 wire-version bump;新 extension 与 server 必须仍使用同一 contract build。
- rendered-card ledger 必须由 v9 升级到 v10,且 v10 只在 `onetalk_rendered_card_ledger` 创建 `[channelAccountId, status]``[channelAccountId, conversationId]` indexes。`listPending(channelAccountId)` 通过前者读取 `[channelAccountId, "pending_ack"]``clearConversationHistory` 通过后者在既有 transaction 中删除精确会话。两者都不恢复 `readAll()`,不清理或修改范围外的 ledger record。
- 实际扩展重载和重新打开 OneTalk 标签才会让 MAIN/ISOLATED/Worker 均加载新 bundle。该用户可见操作不在本 task 的自动验证授权内。
## Test design
- Contract: three valid card variants; product reference canonical/ID match; reject `storeImageUrl`、image URL as source URL、query/credential/fragment/non-product product URL and mismatched ID.
- MAIN observer: fixture each true card; verify exact normalized content and base evidence; reject classifier lookalikes, files, missing data, conversation switch and unsafe URL/text.
- Existing bridge/coordinator regression: accepted observation remains durable-first and waits for confirmed base candidate. No test should reimplement downstream behavior already owned there.
- Storage: a v9 rendered-card record survives the v10 upgrade; scoped `listPending` returns only target-account pending records and does not call object-store `getAll()`.
- Storage: the existing history-cleanup regression proves only the matching account/conversation rendered-card record is deleted through the new scope index.
- Runtime: with user authorization to reload/reopen only if needed, use Chrome read-only probes to verify the three classifier/template predicates and only normalized bridge output. Database acceptance remains conditional on an existing confirmed base fact and valid authenticated Bright connection.
@@ -0,0 +1,3 @@
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Preserve MAIN-to-bridge ownership and exact payload boundaries while adding three typed readers."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Preserve durable-first rendered-card upload, confirmed-base gating, and ACK recovery."}
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Keep the rendered-card fingerprint and conflict snapshot semantics intact after contract narrowing."}
@@ -0,0 +1,35 @@
# 统一 OneTalk React 卡片 reader 实施计划
## Preconditions
- 在修改 shared symbols 前,对 contract normalizer、rendered-card validator 和 card observer entry 执行 GitNexus upstream impact;若 risk 为 HIGH/CRITICAL,先向用户报告 blast radius。
- 读取 `trellis-before-dev` 和受影响 layer 指南,确认当前 worktree、contract build 输入与现有测试命令。
## Ordered implementation
1. 以 failing storage test 复现 `listPending` 的缺失 helper,并覆盖 v9→v10 record 保留、账号/status scope 与禁止 `getAll()`
2. 仅新增 rendered-card ledger 的 `[channelAccountId, status]``[channelAccountId, conversationId]` indexes,升级 IndexedDB 到 v10;将 `listPending` 改为必填账号的 indexed read,并让既有 `clearConversationHistory` 以第二个 index 删除精确会话;不改变 ledger record、ACK 或其它清理语义。
3. 提取 contract-owned product URL canonicalizer,使基础 product content 与 rendered-product validator 共享同一 host/path/ID 规则;保留基础 product 行为的回归。
4. 收窄 `OneTalkRenderedProductContent`:删除 `storeImageUrl`,改 rendered product URL 校验为 canonical product reference + exact `productId`,更新 export/types/contract fixtures/fingerprint assertions。
5. 将 card observer 重构为 common bounded identity/Fiber helpers、strict classifier 和三种 typed reader。保持 observer lifecycle、selected conversation before/after check 和 base evidence 原样。
6. 扩展 shared product item projection 以接受 `productName`;实现 order、inquiry、product 的 exact template predicates 和白名单 projection。商品仅写 `product.imageUrl`,不读取或输出 `hsfImg`
7. 为三类 reader 添加真实形状 fixture,覆盖 valid path、every classifier false path、缺失/unsafe fields、会话失配与 exact output schema;更新 contract and observer targeted tests。
8. 运行 extension/contract focused tests,然后 format/typecheck/build。检查 bridge/coordinator regressions,确认无协议、ledger 状态机、server schema 或 migration diff。
9. 在本地 diff review 后,用 Chrome 只读探针验证三类 runtime predicate;若需要 reload extension/reopen OneTalk tab,先取得用户授权。仅在基础 message candidate 已确认且 Bright 已认证时验证 ledger/DB 后续边界。
## Validation
1. `pnpm --filter @trade-message-center/onetalk-contract test`
2. `pnpm --filter @trade-message-center/chrome-extension test -- onetalk-rendered-card-observer`
3. 相关 contract/extension test files 的 Node test 命令(单次 backend command 如需运行,上限 60 秒)
4. `pnpm format:check`
5. `pnpm typecheck`
6. `pnpm build`
7. `git diff --check`GitNexus `detect_changes()`,并复核所有 changed symbols/flows
8. Chrome read-only runtime probes;可用时再报告 ledger/ACK/DB 证据,不能以 bundle 或单元测试替代。
## Rollback and risk controls
- 在 shared contract validation 失败或 contract build 不一致时,不加载新 extension bundle;修正 contract parity 后再验证。
- 不引入 URL fallback、通用 props crawl、DOM text inference 或自动点击来提高命中率;任何未知模板继续拒绝。
- 只允许 rendered-card ledger 的 v10 index-only 迁移。若后续发现外部环境已有携带 `storeImageUrl` 的 rendered-product rows,停止部署并另建兼容/数据迁移任务,不能在本 task 中静默接纳旧 shape。
@@ -0,0 +1,76 @@
# 统一修复 OneTalk React 卡片观察
## Goal
让已挂载的 OneTalk 订单、询盘与商品卡片均能由 MAIN world 的 React Fiber 白名单读取器产生受控的 rendered-card observation,并沿既有 bridge、durable ledger、Bright 补全事实与 Mind `message.updated` 链路入库和读取。基础消息事实及其同步语义保持不变。
## Confirmed facts
- 已有 observer 仅接受订单 guard`msgType=10010``originalData.cardType=9`),因此询盘和商品在归一化前被拒绝。
- 真实订单通过 identity、卡片类型和模板谓词;其 `productInfoList/productList` 项实际使用 `productName``productImage`。现有共享商品投影仅接受 `title``productTitle``name`,导致订单整体 observation 为 `null`,本地 rendered-card ledger 没有记录。
- 真实询盘使用 `msgType=10010``originalData.cardType=6`。其 Fiber `memoizedProps.data``inquiryCardDTO``displayProducts/products``inquiryContent``inquiryID`;商品项使用 `productName``productImage``productMOQ``productUnit`。该样本中 `inquiryContent``richTextContent``inquiryCardDTO.summary` 相等;可见标签来自 `detailStr``quoteStr``actionName`
- 14 条真实 `msgType=101` 消息中,仅一条在 Fiber 子树有 `props.cardType=54`,因此商品必须同时满足两层 guard。该模板的 `props.data``title``productImage``price``moq``moqUnit``productAction``benefitFirstText``benefitSecondText``promotionTag`
- 商品 `productAction.actionParams.url` 是无凭据、无端口、无 query/fragment 的 `https://chinese.alibaba.com/product-detail/...`;其路径产品 ID 与 `hsfId` 一致,可经现有 `normalizeOneTalkProductUrl` 产生安全的 `sourceUrl``productId`
- 当前 `rendered_product.product.sourceUrl` 错误地使用卡片图片 URL 白名单校验;该白名单不含 `chinese.alibaba.com`,与 `normalizeOneTalkProductUrl` 的唯一允许结果不相容。因此在启用商品 reader 前,contract 必须改为以 canonical product URL 规则验证 `sourceUrl``productId`
- 商品 `productImage``hsfImg` 都通过现有图片 URL 白名单,但本次样本二者相同,无法将 `hsfImg` 证明为独立的店铺图。两个值均在 Fiber 子树的 image URL props 中传递;实际 DOM 图片已被渲染器转换,无法作同值关联。用户明确决定商品只保留商品图,不保留或构造店铺图。
- 既有专用 bridge、rendered-card ledger、`rendered.card.observed/ack`、服务端补全表和读取投影已经处理受控 `OneTalkRenderedCardContent`。本 task 不改变它们的身份、ACK、冲突、授权、发布或数据库语义。
- 已确认 `6be3b839` 新增 rendered-card ledger 的 `listPending()` 后,`61df41d` 的 IndexedDB 索引重构删除了通用 `readAll()`,却没有迁移该 consumer,导致 Chrome extension TypeScript check 在未定义的 `readAll` 处失败。用户已授权把这个阻塞本 task 验证的缺口作为最小前置修复。
## Requirements
### R1. One dispatcher, three strict readers
- 保留 MutationObserver 仅作为 `.message-item-wrapper` 挂载触发器,且保留现有 `messageId + conversationCode + channelAccountId` 身份、会话前后不变和 base evidence 校验。
- 将当前订单专用 reader 改为按明确 classifier 分派的三个 typed reader;不得泛化地递归读取或桥接完整 props。
- 订单仅接受 `msgType=10010``originalData.cardType=9`,并要求已验证的订单模板谓词。
- 询盘仅接受 `msgType=10010``originalData.cardType=6`,并要求已验证的询盘模板谓词。
- 商品仅接受 `msgType=101` 且同一受限 Fiber 子树存在 renderer `cardType=54`,并要求已验证的商品模板谓词;其余 `msgType=101` 必须继续忽略。
- 未知卡、文件卡、身份不完整、会话切换、缺失模板或任一白名单字段不满足时 fail closed,且不退回 DOM 文案、节点顺序、CSS class 或 raw `originalData.params` 推断。
### R2. Exact white-listed projections
- 订单商品投影支持已验证的 `productName``productImage`,并保留现有兼容字段;订单其余内容保持既有受控 contract。
- 询盘投影为 `rendered_inquiry`:商品名/图片、`productMOQ + productUnit``inquiryContent``inquiryID` 和仅标签/可用性的动作信息。不得跨出 action params、callback、URL、原始 DTO 或原始 content。
- 商品投影为 `rendered_product``title``productImage``price``moq + moqUnit`、经 `normalizeOneTalkProductUrl` 清洗的 `productAction.actionParams.url`,以及仅当前可见且非空的服务/促销文案。`storeImageUrl` 必须从 shared contract、校验、fingerprint、reader、bridge payload、持久化 JSON 和测试样例中完全删除。不得桥接 `traceInfo`、action params、action list 或产品原始对象。
- `rendered_product.product.sourceUrl` 必须由 product URL canonicalizer 验证,并与 `productId` 对应;不得使用图片 URL validator 代替产品引用 validator。
- 每个 reader 必须依赖共享 contract 的文本、图片 URL、产品 URL、fingerprint 与 equality 规则;不得自行复制安全校验或构造不安全 URL。
### R3. Preserve the established downstream boundary
- 三类 observation 统一走已有 page bridge、Service Worker coordinator、独立 ledger、Bright ACK 和 server supplement 流程;不得以新消息、重跑基础同步或直接写数据库的方式绕过它。
- 基础 `content`、消息/会话时间字段、checkpoint、history cursor、`message.created` 和重复/冲突语义不得改变。
### R4. Regression and runtime verification
- 为订单、询盘、商品各添加 reader fixture;覆盖真实字段名、strict classifier、模板缺失、身份/会话失配、文件/未知卡及非商品 `msgType=101`
- 订单 regression 必须证明 `productName` 不再使整张卡拒绝;询盘和商品 regression 必须证明只生成各自的 exact contract shape、且不含原始或可执行字段。
- 保留并扩展 observer/bridge/coordinator tests,证明 observation 仍先写 ledger,且只在基础 candidate 已确认后发送。
- 在真实 Chrome 页面无点击、无刷新地验证三类 card reader 只产生规范化 observation;数据库验证必须在对应基础消息存在、连接授权且 ACK 成功的边界下单独报告。
### R5. Repair the rendered-card ledger's indexed pending query
-`listPending` 收窄为必填 `channelAccountId`,并只读取该账号的 `pending_ack` rendered-card ledger records;不得恢复全 store `getAll()` 或在内存过滤所有账号。
- 以新 IndexedDB version 只为 `onetalk_rendered_card_ledger` 新增 `[channelAccountId, status]``[channelAccountId, conversationId]` indexes。现有 records 必须保留,不能清库、重键、回填或改变 ACK/first-content-wins 行为。
- `clearConversationHistory` 必须在其既有 readwrite transaction 中通过 `[channelAccountId, conversationId]` 删除同一会话的 rendered-card records;这恢复其已有测试和 history rebuild 语义,不扩大清理范围。
- 为由旧 v9 数据库升级的 record 保留、pending scope 查询和 conversation cleanup 添加回归;此 scope 只修复 index refactor 的遗漏,不改变 bridge、ledger 状态机、wire、服务端或业务卡 reader 行为。
## Out of scope
- 不改变 rendered-card wire version、bridge envelope、服务端 schema/migration、授权、ACK/冲突状态机或 Mind 事件语义。唯一例外是 R5 的 IndexedDB v10 index-only 升级。
- 不主动点击、翻页、导航、请求额外 OneTalk 接口或改写 React/OneTalk 页面状态。
- 不采集文件、图片消息、文本消息、名片、未知卡或任何未白名单的 Fiber/DOM 字段。
- 不将订单状态变化视为可覆盖的补全更新,也不将卡片动作变为可执行命令。
## Acceptance criteria
- [ ] 已挂载的真实订单可产生 `rendered_order` observation,并且实际 `productName` 作为商品标题进入受控 contract。
- [ ] 满足 `cardType=6` 的询盘可产生完整、受控的 `rendered_inquiry` observation;文件卡和非询盘 `msgType=10010` 不产生 observation。
- [ ] 仅同时满足 `msgType=101` 和 renderer `cardType=54` 的商品可产生 `rendered_product` observation;其他 `msgType=101` 保持忽略。
- [ ] 三类 projection 均通过 contract 验证、没有 raw content/DTO/params/callback/trace 信息、隐藏 ID 或未经批准 URL;`rendered_product` 的任何路径均不包含 `storeImageUrl`
- [ ] canonical 的 `chinese.alibaba.com/product-detail/...` 商品引用通过 rendered-product contract;图片 URL、带 credential/fragment 的 URL、非 canonical product path 或与 URL 不一致的 `productId` 均被拒绝。
- [ ] 订单、询盘、商品的 targeted reader/observer/coordinator tests 通过,且现有 rendered-card、基础同步和历史读取回归不变。
- [ ] 真实 Chrome 只读探针确认三类卡各自命中正确 classifier 和模板谓词;端到端入库仅在不触发主动同步的前提下、已有基础消息确认时验证。
## Decisions
- 商品只保存 `product.imageUrl``storeImageUrl` 没有业务含义或运行时证据,必须完全删除;不得以 `null``hsfImg` 或任何替代字段保留该概念。
@@ -0,0 +1,26 @@
{
"id": "onetalk-rendered-card-readers",
"name": "onetalk-rendered-card-readers",
"title": "统一修复 OneTalk React 卡片观察",
"description": "统一实现订单、询盘和商品的 React Fiber 卡片读取、白名单投影及回归验证。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-15",
"completedAt": null,
"branch": "09-15-onetalk-rendered-card-readers",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -1,191 +1,9 @@
// 从 OneTalk React Fiber 白名单投影订单渲染卡片
// 安装仅以真实 mount 为触发的渲染卡片 observer。
import {
createOneTalkRenderedCardContentFingerprint,
isOneTalkRenderedCardImageUrl,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import { readConversationSelection } from "../page-context.ts";
import type { OneTalkRenderedCardObservation } from "@trade-message-center/onetalk-contract";
import type { OneTalkPageWindow } from "../model.ts";
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
type FiberRecord = {
return?: FiberRecord | null;
child?: FiberRecord | null;
sibling?: FiberRecord | null;
memoizedProps?: unknown;
};
type ItemData = {
messageId: string;
conversationCode: string;
messageType: "rec" | "send";
sendTime: number;
msgType: number;
originalData: Record<string, unknown>;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const text = (value: unknown): string | null =>
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= 64 * 1024 &&
!/[\u0000-\u001f\u007f]/u.test(value)
? value
: null;
const nullableText = (value: unknown): string | null => (value === null ? null : text(value));
const imageUrl = (value: unknown): string | null =>
isOneTalkRenderedCardImageUrl(value) ? value : null;
const scalarId = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0
? value
: typeof value === "number" && Number.isSafeInteger(value)
? String(value)
: null;
const nonNegativeInteger = (value: unknown): number | null =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
const fiberFor = (node: Node): FiberRecord | null => {
for (const key of Object.keys(node)) {
if (key.startsWith("__reactFiber$")) {
const fiber = (node as unknown as Record<string, unknown>)[key];
if (isRecord(fiber)) return fiber as FiberRecord;
}
}
return null;
};
const itemDataFor = (fiber: FiberRecord): ItemData | null => {
let cursor: FiberRecord | null | undefined = fiber;
for (let depth = 0; cursor && depth < 60; depth += 1) {
const current: FiberRecord = cursor;
cursor = current.return;
const props = current.memoizedProps;
if (!isRecord(props) || !isRecord(props.itemData)) continue;
const item = props.itemData;
const messageId = scalarId(item.messageId);
const conversationCode = text(item.conversationCode);
const messageType =
item.messageType === "rec" || item.messageType === "send" ? item.messageType : null;
const sendTime = nonNegativeInteger(item.sendTime);
if (
!messageId ||
!conversationCode ||
!messageType ||
sendTime === null ||
nonNegativeInteger(item.msgType) === null ||
!isRecord(item.originalData)
)
return null;
return {
messageId,
conversationCode,
messageType,
sendTime,
msgType: nonNegativeInteger(item.msgType)!,
originalData: item.originalData,
};
}
return null;
};
const templateDataFor = (fiber: FiberRecord): Record<string, unknown> | null => {
const pending: FiberRecord[] = [fiber];
for (let index = 0; index < pending.length && index < 120; index += 1) {
const current = pending[index]!;
const props = current.memoizedProps;
if (isRecord(props) && isRecord(props.data)) {
const data = props.data;
if (
text(data.cardTitle) &&
Array.isArray(data.productInfoList ?? data.productList) &&
text(data.orderStatusText) &&
text(data.shippingAddress)
)
return data;
}
if (current.child) pending.push(current.child);
if (current.sibling) pending.push(current.sibling);
}
return null;
};
const productFor = (value: unknown): { imageUrl: string | null; title: string } | null => {
if (!isRecord(value)) return null;
const title = text(value.title) ?? text(value.productTitle) ?? text(value.name);
if (!title) return null;
return {
imageUrl: imageUrl(value.imageUrl) ?? imageUrl(value.productImage) ?? imageUrl(value.image),
title,
};
};
const orderContentFor = (data: Record<string, unknown>) => {
const products = data.productInfoList ?? data.productList;
if (!Array.isArray(products)) return null;
const projected = products.map(productFor);
const title = text(data.cardTitle);
const statusText = text(data.orderStatusText);
const shippingAddress = text(data.shippingAddress);
const totalDisplay = text(data.shouldPayAmount) ?? text(data.productAmount);
if (
!title ||
!statusText ||
!shippingAddress ||
!totalDisplay ||
projected.some((item) => item === null)
)
return null;
const action = isRecord(data.orderAction) ? data.orderAction : {};
return {
version: 1 as const,
kind: "rendered_order" as const,
title,
products: projected as { imageUrl: string | null; title: string }[],
productCount: nonNegativeInteger(data.productCount) ?? projected.length,
status: { code: scalarId(data.orderStatus), text: statusText },
payment: { totalDisplay, discountDisplay: nullableText(data.discountAmount) },
delivery: {
shippingAddress,
methodLabel: nullableText(data.shippingMethodText),
dateLabel: nullableText(data.deliveryTimeText),
},
action: {
label: nullableText(action.label) ?? nullableText(action.name),
status: nullableText(action.status),
},
};
};
const observationFor = (
pageWindow: OneTalkPageWindow,
wrapper: Element,
): OneTalkRenderedCardObservation | null => {
const before = readConversationSelection(pageWindow);
if (before.kind !== "single") return null;
const fiber =
fiberFor(wrapper) ??
Array.from(wrapper.querySelectorAll("*"))
.map(fiberFor)
.find((value): value is FiberRecord => value !== null);
if (!fiber) return null;
const item = itemDataFor(fiber);
if (
!item ||
item.conversationCode !== before.conversationId ||
item.msgType !== 10010 ||
item.originalData.cardType !== 9
)
return null;
const template = templateDataFor(fiber);
if (!template) return null;
const content = orderContentFor(template);
const after = readConversationSelection(pageWindow);
if (!content || after.kind !== "single" || after.conversationId !== before.conversationId)
return null;
return {
conversationId: item.conversationCode,
messageId: item.messageId,
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: Date.now(),
};
};
import { readOneTalkRenderedCard } from "./react-card-reader.ts";
const messageListRootFor = (
document: NonNullable<OneTalkPageWindow["document"]>,
@@ -217,26 +35,9 @@ export const installOneTalkRenderedCardObserver = (
seen.add(wrapper);
setTimeout(() => {
if (disposed) return;
const observation = observationFor(pageWindow, wrapper);
if (!observation) return;
const fiber =
fiberFor(wrapper) ??
Array.from(wrapper.querySelectorAll("*"))
.map(fiberFor)
.find((value): value is FiberRecord => value !== null);
const item = fiber ? itemDataFor(fiber) : null;
if (!item) return;
sink(
[observation],
[
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: item.messageType === "rec" ? "received" : "sent",
sentAtMs: item.sendTime,
},
],
);
const read = readOneTalkRenderedCard(pageWindow, wrapper);
if (!read) return;
sink([read.observation], [read.baseEvidence]);
}, 0);
};
let root: Element | null = null;
@@ -0,0 +1,337 @@
// 从 OneTalk React Fiber 白名单投影三类渲染卡片;不让 raw props 离开 MAIN world。
import {
createOneTalkRenderedCardContentFingerprint,
isOneTalkRenderedCardContent,
isOneTalkRenderedCardImageUrl,
isOneTalkRenderedCardText,
normalizeOneTalkProductUrl,
type OneTalkRenderedCardContent,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import { isObjectRecord } from "../../../lib/guards.ts";
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
import { readConversationSelection } from "../page-context.ts";
import type { OneTalkPageWindow } from "../model.ts";
type FiberRecord = {
return?: FiberRecord | null;
child?: FiberRecord | null;
sibling?: FiberRecord | null;
memoizedProps?: unknown;
};
type ItemData = {
messageId: string;
conversationCode: string;
messageType: "rec" | "send";
sendTime: number;
msgType: number;
originalData: Record<string, unknown>;
};
export type OneTalkRenderedCardRead = {
observation: OneTalkRenderedCardObservation;
baseEvidence: OneTalkPageRenderedCardBaseEvidence;
};
type CardClassification =
| { kind: "order"; data: Record<string, unknown> }
| { kind: "inquiry"; data: Record<string, unknown> }
| { kind: "product"; data: Record<string, unknown> };
const MAX_FIBER_ANCESTORS = 60;
const MAX_FIBER_DESCENDANTS = 120;
const text = (value: unknown): string | null => (isOneTalkRenderedCardText(value) ? value : null);
const nullableText = (value: unknown): string | null => (value === null ? null : text(value));
const imageUrl = (value: unknown): string | null =>
isOneTalkRenderedCardImageUrl(value) ? value : null;
const scalarId = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0
? value
: typeof value === "number" && Number.isSafeInteger(value)
? String(value)
: null;
const nonNegativeInteger = (value: unknown): number | null =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
const fiberFor = (node: Node): FiberRecord | null => {
for (const key of Object.keys(node)) {
if (!key.startsWith("__reactFiber$")) continue;
const fiber = (node as unknown as Record<string, unknown>)[key];
if (isObjectRecord(fiber)) return fiber as FiberRecord;
}
return null;
};
const fiberIn = (wrapper: Element): FiberRecord | null =>
fiberFor(wrapper) ??
Array.from(wrapper.querySelectorAll("*"))
.map(fiberFor)
.find((value): value is FiberRecord => value !== null) ??
null;
const itemDataFor = (fiber: FiberRecord): ItemData | null => {
let cursor: FiberRecord | null | undefined = fiber;
for (let depth = 0; cursor && depth < MAX_FIBER_ANCESTORS; depth += 1) {
const current: FiberRecord = cursor;
cursor = current.return;
const props = current.memoizedProps;
if (!isObjectRecord(props) || !isObjectRecord(props.itemData)) continue;
const item = props.itemData;
const messageId = scalarId(item.messageId);
const conversationCode = text(item.conversationCode);
const messageType =
item.messageType === "rec" || item.messageType === "send" ? item.messageType : null;
const sendTime = nonNegativeInteger(item.sendTime);
const msgType = nonNegativeInteger(item.msgType);
if (
!messageId ||
!conversationCode ||
!messageType ||
sendTime === null ||
msgType === null ||
!isObjectRecord(item.originalData)
) {
return null;
}
return {
messageId,
conversationCode,
messageType,
sendTime,
msgType,
originalData: item.originalData,
};
}
return null;
};
const fibersFor = (fiber: FiberRecord): FiberRecord[] => {
const pending: FiberRecord[] = [fiber];
for (let index = 0; index < pending.length && index < MAX_FIBER_DESCENDANTS; index += 1) {
const current = pending[index]!;
if (current.child) pending.push(current.child);
if (current !== fiber && current.sibling) pending.push(current.sibling);
}
return pending.slice(0, MAX_FIBER_DESCENDANTS);
};
const templateDataFor = (
fiber: FiberRecord,
predicate: (data: Record<string, unknown>) => boolean,
): Record<string, unknown> | null => {
for (const current of fibersFor(fiber)) {
const props = current.memoizedProps;
if (!isObjectRecord(props) || !isObjectRecord(props.data) || !predicate(props.data))
continue;
return props.data;
}
return null;
};
const hasRendererCardType = (fiber: FiberRecord, cardType: number): boolean =>
fibersFor(fiber).some((current) => {
const props = current.memoizedProps;
return isObjectRecord(props) && props.cardType === cardType;
});
const productFor = (value: unknown): { imageUrl: string | null; title: string } | null => {
if (!isObjectRecord(value)) return null;
const title =
text(value.productName) ??
text(value.title) ??
text(value.productTitle) ??
text(value.name);
if (!title) return null;
const imageValues = [value.imageUrl, value.productImage, value.image].filter(
(image): image is unknown => image !== undefined && image !== null,
);
if (imageValues.some((value) => imageUrl(value) === null)) return null;
return {
imageUrl:
imageValues.map(imageUrl).find((value): value is string => value !== null) ?? null,
title,
};
};
const orderTemplate = (data: Record<string, unknown>): boolean =>
text(data.cardTitle) !== null &&
Array.isArray(data.productInfoList ?? data.productList) &&
text(data.orderStatusText) !== null &&
text(data.shippingAddress) !== null;
const inquiryTemplate = (data: Record<string, unknown>): boolean =>
isObjectRecord(data.inquiryCardDTO) &&
Array.isArray(data.displayProducts ?? data.products) &&
text(data.inquiryContent) !== null &&
scalarId(data.inquiryID) !== null;
const productTemplate = (data: Record<string, unknown>): boolean => {
const productAction = data.productAction;
return (
text(data.title) !== null &&
imageUrl(data.productImage) !== null &&
text(data.price) !== null &&
text(data.moq) !== null &&
text(data.moqUnit) !== null &&
isObjectRecord(productAction) &&
isObjectRecord(productAction.actionParams) &&
normalizeOneTalkProductUrl(productAction.actionParams.url) !== null
);
};
const classifyRenderedCard = (item: ItemData, fiber: FiberRecord): CardClassification | null => {
if (item.msgType === 10010 && item.originalData.cardType === 9) {
const data = templateDataFor(fiber, orderTemplate);
return data ? { kind: "order", data } : null;
}
if (item.msgType === 10010 && item.originalData.cardType === 6) {
const data = templateDataFor(fiber, inquiryTemplate);
return data ? { kind: "inquiry", data } : null;
}
if (item.msgType === 101 && hasRendererCardType(fiber, 54)) {
const data = templateDataFor(fiber, productTemplate);
return data ? { kind: "product", data } : null;
}
return null;
};
const orderContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
const products = data.productInfoList ?? data.productList;
if (!Array.isArray(products)) return null;
const projected = products.map(productFor);
const title = text(data.cardTitle);
const statusText = text(data.orderStatusText);
const shippingAddress = text(data.shippingAddress);
const totalDisplay = text(data.shouldPayAmount) ?? text(data.productAmount);
if (
!title ||
!statusText ||
!shippingAddress ||
!totalDisplay ||
projected.some((item) => item === null)
) {
return null;
}
const action = isObjectRecord(data.orderAction) ? data.orderAction : {};
return {
version: 1,
kind: "rendered_order",
title,
products: projected as { imageUrl: string | null; title: string }[],
productCount: nonNegativeInteger(data.productCount) ?? projected.length,
status: { code: scalarId(data.orderStatus), text: statusText },
payment: { totalDisplay, discountDisplay: nullableText(data.discountAmount) },
delivery: {
shippingAddress,
methodLabel: nullableText(data.shippingMethodText),
dateLabel: nullableText(data.deliveryTimeText),
},
action: {
label: nullableText(action.label) ?? nullableText(action.name),
status: nullableText(action.status),
},
};
};
const inquiryActionsFor = (data: Record<string, unknown>) =>
[
[data.detailStr, data.detailAction],
[data.quoteStr, data.quoteAction],
[data.actionName, data.inquiryAction],
].flatMap(([label, action]) => {
const approvedLabel = text(label);
return approvedLabel ? [{ label: approvedLabel, available: isObjectRecord(action) }] : [];
});
const inquiryContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
const products = data.displayProducts ?? data.products;
const requirementText = text(data.inquiryContent);
const inquiryReference = scalarId(data.inquiryID);
if (!Array.isArray(products) || !requirementText || !inquiryReference) return null;
const product = productFor(products[0]);
if (!product || !isObjectRecord(products[0])) return null;
const value = text(products[0].productMOQ);
const unit = text(products[0].productUnit);
if (!value || !unit) return null;
return {
version: 1,
kind: "rendered_inquiry",
product,
purchaseQuantity: { value, unit },
requirementText,
inquiryReference,
actions: inquiryActionsFor(data),
};
};
const productContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
const productAction = data.productAction;
if (!isObjectRecord(productAction) || !isObjectRecord(productAction.actionParams)) return null;
const reference = normalizeOneTalkProductUrl(productAction.actionParams.url);
const title = text(data.title);
const productImage = imageUrl(data.productImage);
const priceDisplay = text(data.price);
const value = text(data.moq);
const unit = text(data.moqUnit);
if (!reference || !title || !productImage || !priceDisplay || !value || !unit) return null;
const serviceBadges = [data.benefitFirstText, data.benefitSecondText, data.promotionTag].reduce(
(badges: string[], value) => {
const badge = text(value);
return badge && !badges.includes(badge) ? [...badges, badge] : badges;
},
[],
);
return {
version: 1,
kind: "rendered_product",
product: { title, imageUrl: productImage, ...reference },
priceDisplay,
minimumOrder: { value, unit },
serviceBadges,
};
};
const contentFor = (classification: CardClassification): OneTalkRenderedCardContent | null => {
const content =
classification.kind === "order"
? orderContentFor(classification.data)
: classification.kind === "inquiry"
? inquiryContentFor(classification.data)
: productContentFor(classification.data);
return content && isOneTalkRenderedCardContent(content) ? content : null;
};
/** 读取一次受限 Fiber 卡片,并同时返回 downstream 所需的基础消息证据。 */
export const readOneTalkRenderedCard = (
pageWindow: OneTalkPageWindow,
wrapper: Element,
): OneTalkRenderedCardRead | null => {
const before = readConversationSelection(pageWindow);
if (before.kind !== "single") return null;
const fiber = fiberIn(wrapper);
if (!fiber) return null;
const item = itemDataFor(fiber);
if (!item || item.conversationCode !== before.conversationId) return null;
const classification = classifyRenderedCard(item, fiber);
if (!classification) return null;
const content = contentFor(classification);
const after = readConversationSelection(pageWindow);
if (!content || after.kind !== "single" || after.conversationId !== before.conversationId)
return null;
const observation = {
conversationId: item.conversationCode,
messageId: item.messageId,
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: Date.now(),
};
return {
observation,
baseEvidence: {
conversationId: item.conversationCode,
messageId: item.messageId,
direction: item.messageType === "rec" ? "received" : "sent",
sentAtMs: item.sendTime,
},
};
};
@@ -173,7 +173,7 @@ export type OneTalkRenderedCardLedgerStore = {
conversationId: string,
messageId: string,
) => Promise<OneTalkRenderedCardLedgerRecord | null>;
listPending: (channelAccountId?: string) => Promise<OneTalkRenderedCardLedgerRecord[]>;
listPending: (channelAccountId: string) => Promise<OneTalkRenderedCardLedgerRecord[]>;
markAcknowledged: (input: {
channelAccountId: string;
conversationId: string;
@@ -349,6 +349,8 @@ const ONE_TALK_ANOMALY_BY_ACCOUNT_CONVERSATION_INDEX = "by_account_conversation"
const ONE_TALK_CONTACT_PROFILE_BY_ACCOUNT_INDEX = "by_account";
const ONE_TALK_CONTACT_PROFILE_BY_ACCOUNT_PENDING_OBSERVED_AT_INDEX =
"by_account_pending_observed_at";
const ONE_TALK_RENDERED_CARD_LEDGER_BY_ACCOUNT_STATUS_INDEX = "by_account_status";
const ONE_TALK_RENDERED_CARD_LEDGER_BY_ACCOUNT_CONVERSATION_INDEX = "by_account_conversation";
const ensureIndex = (store: IDBObjectStore, name: string, keyPath: string | string[]): void => {
if (!store.indexNames.contains(name)) store.createIndex(name, keyPath);
@@ -413,6 +415,18 @@ const ensureSyncIndexes = (transaction: IDBTransaction): void => {
"channelAccountId",
"pending.observedAtMs",
]);
const renderedCardLedgerStore = transaction.objectStore(
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
);
ensureIndex(renderedCardLedgerStore, ONE_TALK_RENDERED_CARD_LEDGER_BY_ACCOUNT_STATUS_INDEX, [
"channelAccountId",
"status",
]);
ensureIndex(
renderedCardLedgerStore,
ONE_TALK_RENDERED_CARD_LEDGER_BY_ACCOUNT_CONVERSATION_INDEX,
["channelAccountId", "conversationId"],
);
};
const clearLegacyOneTalkStores = (transaction: IDBTransaction): void => {
@@ -600,16 +614,12 @@ export const createOneTalkRenderedCardLedgerStore = (
);
return serializeRecordWrite(key, () => observeOne(input));
};
const listPending = async (channelAccountId?: string) =>
(
await readAll<OneTalkRenderedCardLedgerRecord>(
await database(),
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
)
).filter(
(record) =>
record.status === "pending_ack" &&
(channelAccountId === undefined || record.channelAccountId === channelAccountId),
const listPending = async (channelAccountId: string) =>
readByIndex<OneTalkRenderedCardLedgerRecord>(
await database(),
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_BY_ACCOUNT_STATUS_INDEX,
[channelAccountId, "pending_ack"],
);
const markAcknowledged = async (input: {
channelAccountId: string;
@@ -1128,6 +1138,10 @@ export const createOneTalkSyncStore = (
[ONE_TALK_MESSAGE_STORE_NAME, ONE_TALK_MESSAGE_BY_ACCOUNT_CONVERSATION_INDEX],
[ONE_TALK_CANDIDATE_STORE_NAME, ONE_TALK_CANDIDATE_BY_ACCOUNT_CONVERSATION_INDEX],
[ONE_TALK_ANOMALY_STORE_NAME, ONE_TALK_ANOMALY_BY_ACCOUNT_CONVERSATION_INDEX],
[
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_BY_ACCOUNT_CONVERSATION_INDEX,
],
] as const) {
const store = transaction.objectStore(storeName);
const request = store.index(indexName).getAllKeys([channelAccountId, conversationId]);
@@ -366,6 +366,76 @@ test("rejects rendered-card observations with forged identity or fingerprint fie
assert.equal(decodeOneTalkPageMessage({ ...message, baseEvidence: [] }), null);
});
test("decodes canonical rendered products and rejects forged product references", () => {
const content = {
version: 1,
kind: "rendered_product",
product: {
imageUrl: "https://img.alicdn.com/product/widget.jpg",
title: "Widget",
sourceUrl: "https://chinese.alibaba.com/product-detail/Widget-123456789.html",
productId: "123456789",
},
priceDisplay: "$9.99",
minimumOrder: { value: "10", unit: "pieces" },
serviceBadges: ["Trade Assurance", "Fast dispatch"],
};
const observation = {
conversationId: "conversation-1",
messageId: "product-message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_000,
};
const message = {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.rendered-card-observed",
channelAccountId: "account-1",
observations: [observation],
baseEvidence: [
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: "received",
sentAtMs: 1_699_999_999_999,
},
],
};
assert.deepEqual(decodeOneTalkPageMessage(message), message);
for (const invalidContent of [
{ ...content, storeImageUrl: "https://img.alicdn.com/store.png" },
{
...content,
product: {
...content.product,
sourceUrl: "https://img.alicdn.com/product/widget.jpg",
},
},
{
...content,
product: { ...content.product, productId: "987654321" },
},
]) {
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [
{
...observation,
content: invalidContent,
contentFingerprint:
createOneTalkRenderedCardContentFingerprint(invalidContent),
},
],
}),
null,
);
}
});
test("deep-clones extended buyer facts at the page bridge boundary", () => {
const created = createOneTalkPageBuyerFactsObservedMessage(
[extendedBuyerFact],
@@ -0,0 +1,368 @@
// 验证 MAIN-world React Fiber reader 只投影订单、询盘与商品的批准字段。
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkRenderedCardContentFingerprint } from "@trade-message-center/onetalk-contract";
import { readOneTalkRenderedCard } from "../src/onetalk/main-page/card-observer/react-card-reader.ts";
const imageUrl = "https://img.alicdn.com/card-product.jpg";
const productUrl = "https://chinese.alibaba.com/product-detail/widget-12345.html";
const pageFor = ({ conversationId = "conversation-1", selectionReads = [conversationId] } = {}) => {
let reads = 0;
return {
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
document: {
querySelectorAll: (selector) => {
assert.equal(selector, ".contact-item-container.selected[data-cid]");
const selected = selectionReads[Math.min(reads, selectionReads.length - 1)];
reads += 1;
return selected
? [{ getAttribute: (name) => (name === "data-cid" ? selected : null) }]
: [];
},
},
};
};
const itemFor = (overrides = {}) => ({
messageId: "message-1",
conversationCode: "conversation-1",
messageType: "rec",
sendTime: 1_700_000_000_000,
msgType: 10010,
originalData: { cardType: 9 },
...overrides,
});
const wrapperFor = ({ item = itemFor(), data, cardType } = {}) => {
const templateFiber = {
memoizedProps: {
...(cardType === undefined ? {} : { cardType }),
...(data === undefined ? {} : { data }),
},
};
const fiber = { memoizedProps: { itemData: item }, child: templateFiber };
return {
__reactFiber$fixture: fiber,
querySelectorAll: (selector) => {
assert.equal(selector, "*");
return [];
},
};
};
const readWithFixedTime = (page, wrapper) => {
const previousNow = Date.now;
Date.now = () => 1_700_000_000_123;
try {
return readOneTalkRenderedCard(page, wrapper);
} finally {
Date.now = previousNow;
}
};
test("reads an order that uses productName into the exact normalized card and base evidence", () => {
const content = {
version: 1,
kind: "rendered_order",
title: "Order #1",
products: [{ title: "Widget", imageUrl }],
productCount: 1,
status: { code: "9", text: "Paid" },
payment: { totalDisplay: "$10", discountDisplay: "$1" },
delivery: { shippingAddress: "Hangzhou", methodLabel: "Express", dateLabel: "Tomorrow" },
action: { label: "View order", status: "available" },
};
const read = readWithFixedTime(
pageFor(),
wrapperFor({
data: {
cardTitle: content.title,
productInfoList: [{ productName: "Widget", productImage: imageUrl }],
productCount: 1,
orderStatus: 9,
orderStatusText: "Paid",
shouldPayAmount: "$10",
discountAmount: "$1",
shippingAddress: "Hangzhou",
shippingMethodText: "Express",
deliveryTimeText: "Tomorrow",
orderAction: { label: "View order", status: "available" },
},
}),
);
assert.deepEqual(read, {
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_123,
},
baseEvidence: {
conversationId: "conversation-1",
messageId: "message-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
},
});
});
test("reads an inquiry into only the approved product, requirement, reference, and ordered action fields", () => {
const content = {
version: 1,
kind: "rendered_inquiry",
product: { title: "Widget", imageUrl },
purchaseQuantity: { value: "100", unit: "pieces" },
requirementText: "Need 100 widgets\nSecond line",
inquiryReference: "inquiry-1",
actions: [
{ label: "Details", available: true },
{ label: "Quote", available: false },
{ label: "Send inquiry", available: true },
],
};
const read = readWithFixedTime(
pageFor(),
wrapperFor({
item: itemFor({ originalData: { cardType: 6 } }),
data: {
inquiryCardDTO: {
summary: "Need 100 widgets\nSecond line",
unapproved: "must not cross",
},
displayProducts: [
{
productName: "Widget",
productImage: imageUrl,
productMOQ: "100",
productUnit: "pieces",
hiddenProductField: "must not cross",
},
],
inquiryContent: "Need 100 widgets\nSecond line",
inquiryID: "inquiry-1",
detailStr: "Details",
detailAction: { params: "must not cross" },
quoteStr: "Quote",
quoteAction: null,
actionName: "Send inquiry",
inquiryAction: { callback: "must not cross" },
},
}),
);
assert.deepEqual(read, {
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_123,
},
baseEvidence: {
conversationId: "conversation-1",
messageId: "message-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
},
});
});
test("reads a product only through the product renderer and omits every raw or store-image field", () => {
const content = {
version: 1,
kind: "rendered_product",
product: {
title: "Widget",
imageUrl,
sourceUrl: productUrl,
productId: "12345",
},
priceDisplay: "$10",
minimumOrder: { value: "2", unit: "pieces" },
serviceBadges: ["Fast delivery", "Buyer protection"],
};
const read = readWithFixedTime(
pageFor(),
wrapperFor({
item: itemFor({ msgType: 101, originalData: {} }),
cardType: 54,
data: {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: { actionParams: { url: productUrl, unsafe: "must not cross" } },
benefitFirstText: "Fast delivery",
benefitSecondText: "Fast delivery",
promotionTag: "Buyer protection",
hsfImg: "https://img.alicdn.com/store.jpg",
storeImageUrl: "https://img.alicdn.com/store.jpg",
traceInfo: { unsafe: "must not cross" },
},
}),
);
assert.deepEqual(read, {
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_123,
},
baseEvidence: {
conversationId: "conversation-1",
messageId: "message-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
},
});
assert.deepEqual(Object.keys(read?.observation.content.product ?? {}).sort(), [
"imageUrl",
"productId",
"sourceUrl",
"title",
]);
for (const forbidden of ["storeImageUrl", "hsfImg", "traceInfo", "actionParams", "data"]) {
assert.equal(forbidden in (read?.observation.content.product ?? {}), false);
assert.equal(forbidden in (read?.observation.content ?? {}), false);
}
});
test("rejects files, lookalikes, incomplete templates and identity or session changes", () => {
const orderData = {
cardTitle: "Order #1",
productInfoList: [{ productName: "Widget", productImage: imageUrl }],
orderStatusText: "Paid",
shouldPayAmount: "$10",
shippingAddress: "Hangzhou",
};
const cases = [
{
name: "file card",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ originalData: { cardType: 12 } }),
data: orderData,
}),
},
{
name: "unknown 10010 card",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ originalData: { cardType: 7 } }),
data: orderData,
}),
},
{
name: "non-product 101 card",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ msgType: 101, originalData: {} }),
data: orderData,
}),
},
{
name: "missing template field",
page: pageFor(),
wrapper: wrapperFor({ data: { ...orderData, shippingAddress: undefined } }),
},
{
name: "unsafe image URL",
page: pageFor(),
wrapper: wrapperFor({
data: {
...orderData,
productInfoList: [
{ productName: "Widget", productImage: "https://evil.example/image.jpg" },
],
},
}),
},
{
name: "unsafe product reference",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ msgType: 101, originalData: {} }),
cardType: 54,
data: {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: {
actionParams: { url: "https://evil.example/widget-12345.html" },
},
},
}),
},
{
name: "incomplete identity",
page: pageFor(),
wrapper: wrapperFor({ item: itemFor({ messageId: "" }), data: orderData }),
},
{
name: "selection changes while reading",
page: pageFor({ selectionReads: ["conversation-1", "conversation-2"] }),
wrapper: wrapperFor({ data: orderData }),
},
];
for (const entry of cases) {
assert.equal(readWithFixedTime(entry.page, entry.wrapper), null, entry.name);
}
});
test("does not classify a product renderer beyond the bounded Fiber subtree scan", () => {
const data = {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: { actionParams: { url: productUrl } },
};
const fiber = {
memoizedProps: { itemData: itemFor({ msgType: 101, originalData: {} }), data },
};
let cursor = fiber;
for (let index = 0; index < 120; index += 1) {
cursor.sibling = { memoizedProps: {} };
cursor = cursor.sibling;
}
cursor.memoizedProps.cardType = 54;
const wrapper = {
__reactFiber$fixture: fiber,
querySelectorAll: () => [],
};
assert.equal(readWithFixedTime(pageFor(), wrapper), null);
});
test("does not borrow a product renderer from the sibling of the current message root", () => {
const productData = {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: { actionParams: { url: productUrl } },
};
const rootFiber = {
memoizedProps: { itemData: itemFor({ msgType: 101, originalData: {} }) },
sibling: { memoizedProps: { cardType: 54, data: productData } },
};
const wrapper = {
__reactFiber$fixture: rootFiber,
querySelectorAll: () => [],
};
assert.equal(readWithFixedTime(pageFor(), wrapper), null);
});
@@ -212,6 +212,7 @@ class FakeFactory {
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
ONE_TALK_CONVERSATION_BOOTSTRAP_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
]);
request.transaction = transaction;
transaction.oncomplete = () => request.onsuccess?.();
@@ -417,6 +418,70 @@ test("preserves v9 ledger records while adding the buyer facts store in v10", as
}
});
test("preserves v9 rendered-card records and reads pending ACKs through the account-status index", async () => {
const factory = new FakeFactory(9);
for (const storeName of [
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
ONE_TALK_CONVERSATION_BOOTSTRAP_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
]) {
factory.database.createObjectStore(storeName);
}
const renderedLedger = factory.database.stores.get(ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME);
const content = {
version: 1,
kind: "rendered_order",
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const pendingAccountOne = {
key: JSON.stringify(["account-1", "conversation-1", "message-1"]),
channelAccountId: "account-1",
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 100,
baseDirection: "received",
baseSentAtMs: 99,
status: "pending_ack",
firstObservedAt: 100,
updatedAt: 100,
};
renderedLedger.put(pendingAccountOne);
renderedLedger.put({
...pendingAccountOne,
key: JSON.stringify(["account-2", "conversation-2", "message-2"]),
channelAccountId: "account-2",
conversationId: "conversation-2",
messageId: "message-2",
});
renderedLedger.put({
...pendingAccountOne,
key: JSON.stringify(["account-1", "conversation-3", "message-3"]),
conversationId: "conversation-3",
messageId: "message-3",
status: "confirmed",
});
const store = createOneTalkRenderedCardLedgerStore(factory, () => 500);
assert.deepEqual(await store.listPending("account-1"), [pendingAccountOne]);
assert.equal(factory.database.version, ONE_TALK_SYNC_DATABASE_VERSION);
assert.equal(renderedLedger.indexNames.contains("by_account_status"), true);
assert.equal(renderedLedger.indexNames.contains("by_account_conversation"), true);
assert.equal(renderedLedger.objectStoreGetAllCalls, 0);
});
test("clears only one conversation history ledger after its transaction commits", async () => {
const factory = new FakeFactory();
const store = createOneTalkSyncStore(factory, () => 500);
@@ -444,6 +509,24 @@ test("clears only one conversation history ledger after its transaction commits"
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 500,
});
for (const [channelAccountId, conversationId, messageId] of [
["account-1", "conversation-2", "message-2"],
["account-2", "conversation-1", "message-3"],
]) {
await renderedCardStore.observe({
channelAccountId,
observation: {
conversationId,
messageId,
content: renderedCardContent,
contentFingerprint:
createOneTalkRenderedCardContentFingerprint(renderedCardContent),
observedAtMs: 500,
},
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 500,
});
}
await store.persistObservedBatch({
channelAccountId: "account-1",
conversationId: "conversation-1",
@@ -489,6 +572,8 @@ test("clears only one conversation history ledger after its transaction commits"
await store.clearConversationHistory("account-1", "conversation-1");
assert.equal(await renderedCardStore.get("account-1", "conversation-1", "message-1"), null);
assert.notEqual(await renderedCardStore.get("account-1", "conversation-2", "message-2"), null);
assert.notEqual(await renderedCardStore.get("account-2", "conversation-1", "message-3"), null);
assert.equal(await store.getCheckpoint("account-1", "conversation-1"), null);
assert.deepEqual(await store.listCandidates("account-1", "conversation-1"), []);
assert.deepEqual(await store.listAnomalies("account-1", "conversation-1"), []);
@@ -501,6 +586,7 @@ test("clears only one conversation history ledger after its transaction commits"
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
]) {
assert.equal(factory.database.stores.get(storeName).objectStoreGetAllCalls, 0);
}
@@ -0,0 +1,291 @@
# OneTalk Rendered Card Readers Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use `subagent-driven-development` (recommended) or `executing-plans` to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Safely observe and persist normalized order, inquiry, and product card content from OneTalk React Fiber while removing the non-existent `storeImageUrl` concept.
**Architecture:** Extract a contract-owned canonical product-reference module shared by base-message and rendered-card validation. Split the MAIN-world card reader from its MutationObserver lifecycle, use one strict dispatcher and three bounded typed projections, then forward the unchanged normalized observation through the existing bridge and durable coordinator.
**Tech Stack:** TypeScript, Node test runner, Chrome MV3 content scripts, React Fiber runtime data, pnpm workspace.
**Spec:** `.trellis/tasks/09-15-onetalk-rendered-card-readers/{prd.md,design.md,implement.md}`
## Global Constraints
- MAIN world may output only whitelisted normalized plain data; raw props, action params, callbacks, trace data, DOM/Fiber references, credentials, query strings, and fragments never cross the bridge.
- Identity remains `channelAccountId + conversationId + messageId`; no DOM text, ordering, or CSS inference is permitted.
- Unknown card templates fail closed. No generic props crawl or active page interaction is allowed.
- Existing bridge, ledger, ACK/conflict state machine, base-message sync, database schema, and Mind events retain their current behavior.
- `storeImageUrl` is removed completely. Products retain only `product.imageUrl`.
- Before each shared-symbol edit, run GitNexus upstream impact; report HIGH/CRITICAL risk before proceeding.
- The accepted prerequisite repair must preserve the IndexedDB index-refactor invariant: rendered-card pending recovery is account-scoped and index-backed, never a restored full-store scan.
### Task 0: Repair the rendered-card ledger consumer omitted by the IndexedDB index refactor
**Files:**
- Modify: `apps/chrome-extension/src/onetalk/service-worker/storage.ts`
- Test: `apps/chrome-extension/test/onetalk-sync-storage.test.js`
- Test: `apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js` only if its ledger fake must reflect the required account argument
**Root cause:** `6be3b839` introduced rendered-card `listPending()` using `readAll`; `61df41d` replaced `readAll` with indexed readers but missed this consumer. This is a prerequisite because it blocks extension typecheck.
- [ ] Write a failing storage test that calls rendered-card `listPending("account-1")` across records for two accounts and terminal/pending states. It must expect only account-1 `pending_ack` records and assert the ledger object store made no `getAll()` call.
- [ ] Extend the existing old-version migration fixture with a v9 rendered-card record; run the focused test and record the failure caused by unresolved `readAll`.
- [ ] Increment `ONE_TALK_SYNC_DATABASE_VERSION` to v10. In `onupgradeneeded`, create only `[channelAccountId, status]` and `[channelAccountId, conversationId]` indexes on `onetalk_rendered_card_ledger`, without record rewrite, clear, or backfill.
- [ ] Make `listPending(channelAccountId: string)` use `readByIndex(..., [channelAccountId, "pending_ack"])`. It must not accept an omitted account or reintroduce `readAll`.
- [ ] Include the rendered-card ledger in `clearConversationHistory`'s existing index-key deletion loop using its account/conversation index. This restores the pre-existing cleanup test and does not change the transaction's scope or status-machine behavior.
- [ ] Run focused storage/coordinator tests and `pnpm exec tsc --noEmit -p apps/chrome-extension/tsconfig.json` before accepting the prerequisite. Preserve durable-first, first-content-wins, ACK and reconnect behavior.
---
### Task 1: Share canonical product URL validation across content contracts
**Files:**
- Create: `packages/onetalk-contract/src/product-url.ts`
- Modify: `packages/onetalk-contract/src/content.ts`
- Modify: `packages/onetalk-contract/src/index.ts`
- Test: `packages/onetalk-contract/test/contract.test.ts`
**Consumes:** The current `normalizeOneTalkProductUrl` host/path/ID rules in `content.ts`.
**Produces:**
```ts
export type OneTalkProductReference = { sourceUrl: string; productId: string };
export const normalizeOneTalkProductUrl = (value: unknown): OneTalkProductReference | null;
export const isOneTalkProductReference = (value: unknown): value is OneTalkProductReference;
```
- [ ] **Step 1: Add failing contract tests for a canonical reference and invalid variants.**
```ts
assert.deepEqual(normalizeOneTalkProductUrl(validProductUrl), {
sourceUrl: validProductUrl,
productId: "1601456609478",
});
assert.equal(isOneTalkProductReference({ sourceUrl: validProductUrl, productId: "wrong" }), false);
assert.equal(normalizeOneTalkProductUrl(urlWithFragment), null);
```
- [ ] **Step 2: Run the contract test before refactoring.**
Run: `pnpm --filter @trade-message-center/onetalk-contract test`
Expected: the new reference-validator assertions fail because no standalone product-reference validator exists.
- [ ] **Step 3: Move only the current canonical URL parse logic into `product-url.ts`.**
Keep the current `chinese.alibaba.com/product-detail/...` path rule and query stripping semantics unchanged. Define `isOneTalkProductReference` by validating the exact two keys and checking that `normalizeOneTalkProductUrl(sourceUrl)` returns the same `sourceUrl` and `productId`.
- [ ] **Step 4: Import the normalizer from `content.ts` and export the new module through `index.ts`.**
The base `kind: "product"` validation must keep its public behavior. Do not import `content.ts` from `rendered-cards.ts`, because `content.ts` already consumes the rendered-card union.
- [ ] **Step 5: Re-run the contract suite.**
Run: `pnpm --filter @trade-message-center/onetalk-contract test`
Expected: all base-product URL tests and new canonical-reference tests pass.
### Task 2: Narrow rendered-product JSON to the approved product image and reference
**Files:**
- Modify: `packages/onetalk-contract/src/rendered-cards.ts`
- Modify: `packages/onetalk-contract/src/index.ts`
- Test: `packages/onetalk-contract/test/contract.test.ts`
- Test: `apps/chrome-extension/test/onetalk-page-bridge.test.js`
- Test: `apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js`
**Consumes:** `OneTalkProductReference` and the existing card image/text/quantity validators.
**Produces:**
```ts
type OneTalkRenderedProductContent = {
version: 1;
kind: "rendered_product";
product: OneTalkRenderedCardImage & OneTalkProductReference;
priceDisplay: string;
minimumOrder: OneTalkRenderedCardQuantity;
serviceBadges: string[];
};
```
- [ ] **Step 1: Add failing strict-shape tests.**
```ts
assert.equal(isOneTalkRenderedCardContent(validRenderedProduct), true);
assert.equal(isOneTalkRenderedCardContent({ ...validRenderedProduct, storeImageUrl: null }), false);
assert.equal(
isOneTalkRenderedCardContent({
...validRenderedProduct,
product: { ...validRenderedProduct.product, sourceUrl: "https://img.alicdn.com/item.jpg" },
}),
false,
);
```
- [ ] **Step 2: Run focused contract tests to establish failure.**
Run: `pnpm --filter @trade-message-center/onetalk-contract test`
Expected: the valid canonical product reference is rejected by the current image-host validator, and the old shape still accepts `storeImageUrl`.
- [ ] **Step 3: Remove `storeImageUrl` from the type and exact-key check.**
Replace `isOneTalkRenderedCardImageUrl(product.sourceUrl)` with `isOneTalkProductReference({ sourceUrl: product.sourceUrl, productId: product.productId })`, after the full product object has separately passed its exact-key, image, and title validation. This retains the exact two-key product-reference contract without allowing the rendered product's additional fields to bypass it.
- [ ] **Step 4: Update every rendered-product fixture or assertion.**
Search the entire workspace for `storeImageUrl` and remove it from valid payloads. Add one negative case at each strict decoder boundary so the removed key cannot silently re-enter through bridge or coordinator fixtures.
- [ ] **Step 5: Run contract and downstream targeted tests.**
Run:
```text
pnpm --filter @trade-message-center/onetalk-contract test
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-page-bridge onetalk-rendered-card-coordinator
```
Expected: valid product payloads survive cloning/fingerprinting; any `storeImageUrl`, invalid URL, or mismatched product ID is rejected.
### Task 3: Make the Fiber reader independently testable
**Files:**
- Create: `apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts`
- Modify: `apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts`
- Create: `apps/chrome-extension/test/onetalk-rendered-card-reader.test.js`
- Modify: `apps/chrome-extension/test/onetalk-rendered-card-observer.test.js`
**Consumes:** `OneTalkRenderedCardContent`, `createOneTalkRenderedCardContentFingerprint`, `normalizeOneTalkProductUrl`, `readConversationSelection`, and `OneTalkPageRenderedCardBaseEvidence`.
**Produces:**
```ts
export type OneTalkRenderedCardRead = {
observation: OneTalkRenderedCardObservation;
baseEvidence: OneTalkPageRenderedCardBaseEvidence;
};
export const readOneTalkRenderedCard = (
pageWindow: OneTalkPageWindow,
wrapper: Element,
): OneTalkRenderedCardRead | null;
```
- [ ] **Step 1: Write fixtures that model bounded React Fiber links.**
The order fixture must use `productName`; inquiry must use `cardType: 6`, `inquiryCardDTO`, `displayProducts`, `inquiryContent`, and `inquiryID`; product must use `msgType: 101`, descendant `cardType: 54`, `productAction.actionParams.url`, and visible benefit/promotion strings. Each fixture must expose a selected `data-cid` matching `conversationCode`.
- [ ] **Step 2: Add failing reader assertions for all three valid shapes.**
```ts
assert.equal(
readOneTalkRenderedCard(page, orderWrapper)?.observation.content.kind,
"rendered_order",
);
assert.equal(
readOneTalkRenderedCard(page, inquiryWrapper)?.observation.content.kind,
"rendered_inquiry",
);
assert.equal(
readOneTalkRenderedCard(page, productWrapper)?.observation.content.kind,
"rendered_product",
);
```
Assert the full approved output shape, including inquiry action label order and product badge order. Assert that product output has no `storeImageUrl`, `hsfImg`, action params, trace data, or raw DTO fields.
- [ ] **Step 3: Add failing rejection cases.**
Cover `cardType: 12`, unknown 10010 cards, `msgType: 101` without renderer `cardType: 54`, missing template fields, unsafe image/reference URL, incomplete identity, and a selected conversation that changes between before/after reads.
- [ ] **Step 4: Implement bounded common Fiber and identity helpers.**
Move `FiberRecord`, `fiberFor`, `itemDataFor`, text/scalar/image guards, the fixed traversal bounds, and before/after conversation checks into `react-card-reader.ts`. Return both observation and base evidence so `entry.ts` does not re-read Fiber state after normalization.
- [ ] **Step 5: Implement an explicit classifier and three direct template predicates.**
```ts
const classifier = classifyRenderedCard(item, fiber);
switch (classifier.kind) {
case "order":
return readOrderTemplate(classifier.data);
case "inquiry":
return readInquiryTemplate(classifier.data);
case "product":
return readProductTemplate(classifier.data);
default:
return null;
}
```
Do not add a generic record walk. Product renderer detection remains bounded to the same Fiber subtree. Deduplicate badges in fixed observed order and only emit nonempty validated text.
- [ ] **Step 6: Keep `entry.ts` lifecycle-only and run reader tests.**
`installOneTalkRenderedCardObserver` continues to own WeakSet deduplication, `setTimeout(0)`, root rebinding, `pagehide`, and mutation discovery. It calls the new reader once and forwards paired observation/base evidence.
Run:
```text
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-rendered-card-reader onetalk-rendered-card-observer
```
Expected: all valid fixtures normalize exactly once; every lookalike produces no observation.
### Task 4: Verify end-to-end boundaries and finalize the change
**Files:**
- Modify only test files shown by the `storeImageUrl` and reader-symbol searches.
- Modify task artifacts only if implementation changes a documented invariant.
**Consumes:** Completed contract and reader tests.
**Produces:** A reviewed, type-safe, buildable reader change with documented runtime evidence limits.
- [ ] **Step 1: Run targeted bridge and coordinator regressions.**
Run:
```text
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-page-bridge onetalk-rendered-card-coordinator onetalk-sync-storage
```
Expected: normalized observations remain durable-first and are sent only after matching base candidate confirmation.
- [ ] **Step 2: Run workspace static and build validation.**
Run:
```text
pnpm format:check
pnpm typecheck
pnpm build
git diff --check
```
Expected: no formatting/type/build failure; no server migration or generated artifact change.
- [ ] **Step 3: Perform GitNexus review and code review.**
Run `detect_changes()` against the active worktree, inspect all changed symbols and affected flows, and check that no change escapes the contract/MAIN reader/test scope. Review the diff for raw-prop leakage, URL fallback, duplicated validation, hidden success paths, and `storeImageUrl` remnants.
- [ ] **Step 4: Request permission before user-visible runtime refresh.**
Only if runtime confirmation needs new code loaded, ask the user to authorize extension reload and reopening the OneTalk tab. After authorization, use Chrome read-only probes to confirm the exact three classifiers/template predicates and the normalized bridge/ledger count. Do not click, navigate, synchronize, or read sensitive card values.
- [ ] **Step 5: Record validation and commit one cohesive change.**
Use the repository commit style after all checks pass. Include contract, reader, test, and task documentation changes in one commit; do not include generated extension artifacts.
## Plan self-review
- Spec coverage: Tasks 1-2 implement contract URL safety and field removal; Task 3 implements all three strict readers; Task 4 protects downstream semantics and runtime validation.
- Placeholder scan: no TODO/TBD steps remain.
- Type consistency: Task 1 defines `OneTalkProductReference`; Task 2 consumes it in the rendered product contract; Task 3 consumes the public normalizer and returns one paired observation/evidence value; Task 4 exercises existing bridge/coordinator interfaces without changing them.
+3 -58
View File
@@ -2,6 +2,7 @@
import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts";
import { isOneTalkProductReference } from "./product-url.ts";
import { isOneTalkRenderedCardContent, type OneTalkRenderedCardContent } from "./rendered-cards.ts";
export const ONETALK_CONTENT_VERSION = 1 as const;
@@ -148,9 +149,6 @@ const MAX_EXTENSION_LENGTH = 64;
const MAX_MD5_LENGTH = 128;
const MAX_MEDIA_URL_LENGTH = 8 * 1024;
const MAX_ORDER_ACTIONS = 100;
const PRODUCT_DETAIL_HOST = "chinese.alibaba.com";
const PRODUCT_DETAIL_PATH = /^\/product-detail\/(?:[A-Za-z0-9]+-)+([1-9]\d*)\.html$/u;
const PRODUCT_URL_AUTHORITY = /^https:\/\/([^/?#]*)/u;
const CONTENT_KEYS = {
text: ["version", "kind", "text"],
@@ -381,53 +379,6 @@ const isOptionalMediaUrl = (
return value === null || isMediaUrl(value, expectedPath, allowedFileActions);
};
/**
* 只在 MAIN world 解析 OneTalk 原始商品详情 URL;query 会在这里被剥离,不能进入跨层合同。
*/
export const normalizeOneTalkProductUrl = (
value: unknown,
): { sourceUrl: string; productId: string } | null => {
const authority =
typeof value === "string" ? PRODUCT_URL_AUTHORITY.exec(value)?.[1] : undefined;
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > MAX_TEXT_LENGTH ||
value.trim() !== value ||
/\s/u.test(value) ||
!value.startsWith("https://") ||
authority !== PRODUCT_DETAIL_HOST ||
value.includes("#")
) {
return null;
}
const rawPath = value.slice(`https://${PRODUCT_DETAIL_HOST}`.length).split("?", 1)[0];
const pathnameMatch = PRODUCT_DETAIL_PATH.exec(rawPath);
if (pathnameMatch === null) return null;
try {
const url = new URL(value);
if (
url.protocol !== "https:" ||
url.hostname !== PRODUCT_DETAIL_HOST ||
url.port.length > 0 ||
url.username.length > 0 ||
url.password.length > 0 ||
url.hash.length > 0 ||
url.pathname !== rawPath
) {
return null;
}
return {
sourceUrl: `${url.origin}${rawPath}`,
productId: pathnameMatch[1],
};
} catch {
return null;
}
};
const isOneTalkTextContent = (value: Record<string, unknown>): value is OneTalkTextContent => {
return (
hasExactKeys(value, CONTENT_KEYS.text) &&
@@ -543,17 +494,11 @@ const isOneTalkProductContent = (
!hasExactKeys(value, CONTENT_KEYS.product) ||
value.version !== ONETALK_CONTENT_VERSION ||
value.kind !== "product" ||
typeof value.sourceUrl !== "string" ||
typeof value.productId !== "string"
!isOneTalkProductReference({ sourceUrl: value.sourceUrl, productId: value.productId })
) {
return false;
}
const normalized = normalizeOneTalkProductUrl(value.sourceUrl);
return (
normalized !== null &&
normalized.sourceUrl === value.sourceUrl &&
normalized.productId === value.productId
);
return true;
};
/** 解码 exact-shape 的已归一化 OneTalk 消息内容。 */
+3
View File
@@ -4,6 +4,8 @@ export { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
export * from "./authorization.ts";
export * from "./content.ts";
export * from "./decoder.ts";
export { isOneTalkProductReference, normalizeOneTalkProductUrl } from "./product-url.ts";
export type { OneTalkProductReference } from "./product-url.ts";
export {
ONETALK_CONNECTION_TYPES,
@@ -129,6 +131,7 @@ export {
createOneTalkRenderedCardObservedFrame,
isOneTalkRenderedCardContent,
isOneTalkRenderedCardImageUrl,
isOneTalkRenderedCardText,
isSameOneTalkRenderedCardContent,
isValidOneTalkRenderedCardPayload,
} from "./rendered-cards.ts";
@@ -0,0 +1,74 @@
// 规范化 OneTalk 商品详情引用
import { isPlainRecord } from "./guards.ts";
const MAX_PRODUCT_URL_LENGTH = 64 * 1024;
const PRODUCT_DETAIL_HOST = "chinese.alibaba.com";
const PRODUCT_DETAIL_PATH = /^\/product-detail\/(?:[A-Za-z0-9]+-)+([1-9]\d*)\.html$/u;
const PRODUCT_URL_AUTHORITY = /^https:\/\/([^/?#]*)/u;
const PRODUCT_REFERENCE_KEYS = ["sourceUrl", "productId"] as const;
export type OneTalkProductReference = { sourceUrl: string; productId: string };
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
const actualKeys = Object.keys(value).sort();
const expectedKeys = [...keys].sort();
return (
actualKeys.length === expectedKeys.length &&
actualKeys.every((key, index) => key === expectedKeys[index])
);
};
/** 只保留无凭据、无 fragment 的 OneTalk 商品详情引用。 */
export const normalizeOneTalkProductUrl = (value: unknown): OneTalkProductReference | null => {
const authority =
typeof value === "string" ? PRODUCT_URL_AUTHORITY.exec(value)?.[1] : undefined;
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > MAX_PRODUCT_URL_LENGTH ||
value.trim() !== value ||
/\s/u.test(value) ||
!value.startsWith("https://") ||
authority !== PRODUCT_DETAIL_HOST ||
value.includes("#")
) {
return null;
}
const rawPath = value.slice(`https://${PRODUCT_DETAIL_HOST}`.length).split("?", 1)[0];
const pathnameMatch = PRODUCT_DETAIL_PATH.exec(rawPath);
if (pathnameMatch === null) return null;
try {
const url = new URL(value);
if (
url.protocol !== "https:" ||
url.hostname !== PRODUCT_DETAIL_HOST ||
url.port.length > 0 ||
url.username.length > 0 ||
url.password.length > 0 ||
url.hash.length > 0 ||
url.pathname !== rawPath
) {
return null;
}
return {
sourceUrl: `${url.origin}${rawPath}`,
productId: pathnameMatch[1],
};
} catch {
return null;
}
};
/** 验证 exact-shape 的已规范化商品引用。 */
export const isOneTalkProductReference = (value: unknown): value is OneTalkProductReference => {
if (!isPlainRecord(value) || !hasExactKeys(value, PRODUCT_REFERENCE_KEYS)) return false;
const normalized = normalizeOneTalkProductUrl(value.sourceUrl);
return (
normalized !== null &&
normalized.sourceUrl === value.sourceUrl &&
normalized.productId === value.productId
);
};
+30 -24
View File
@@ -2,6 +2,7 @@
import type { OneTalkMindScope, OneTalkPluginScope } from "./connection.ts";
import { isPlainRecord } from "./guards.ts";
import { isOneTalkProductReference } from "./product-url.ts";
import { ONETALK_PROTOCOL_VERSION } from "./wire.ts";
import type { OneTalkBaseFrame, OneTalkFrameContext } from "./wire.ts";
@@ -29,7 +30,6 @@ export type OneTalkRenderedInquiryContent = {
export type OneTalkRenderedProductContent = {
version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION;
kind: "rendered_product";
storeImageUrl: string | null;
product: OneTalkRenderedCardImage & { sourceUrl: string; productId: string };
priceDisplay: string;
minimumOrder: OneTalkRenderedCardQuantity;
@@ -92,12 +92,17 @@ const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]):
actual.length === expected.length && actual.every((key, index) => key === expected[index])
);
};
const isText = (value: unknown, maximum = MAX_TEXT_LENGTH): value is string =>
const isRenderedCardText = (value: unknown, maximum = MAX_TEXT_LENGTH): value is string =>
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= maximum &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value);
const isIdentifier = (value: unknown): value is string => isText(value, MAX_IDENTIFIER_LENGTH);
/** 验证渲染卡片白名单文本,保留换行并拒绝非文本控制字符。 */
export const isOneTalkRenderedCardText = (value: unknown): value is string =>
isRenderedCardText(value);
const isIdentifier = (value: unknown): value is string =>
isRenderedCardText(value, MAX_IDENTIFIER_LENGTH);
const isSafeNonNegativeInteger = (value: unknown): value is number =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
@@ -126,16 +131,16 @@ const isCardImage = (value: unknown): value is OneTalkRenderedCardImage =>
isPlainRecord(value) &&
hasExactKeys(value, ["imageUrl", "title"]) &&
isNullableImageUrl(value.imageUrl) &&
isText(value.title);
isOneTalkRenderedCardText(value.title);
const isQuantity = (value: unknown): value is OneTalkRenderedCardQuantity =>
isPlainRecord(value) &&
hasExactKeys(value, ["value", "unit"]) &&
isText(value.value) &&
isText(value.unit);
isOneTalkRenderedCardText(value.value) &&
isOneTalkRenderedCardText(value.unit);
const isAction = (value: unknown): value is OneTalkRenderedCardAction =>
isPlainRecord(value) &&
hasExactKeys(value, ["label", "available"]) &&
isText(value.label) &&
isOneTalkRenderedCardText(value.label) &&
typeof value.available === "boolean";
const canonicalize = (value: unknown): string => {
@@ -190,7 +195,7 @@ export const isOneTalkRenderedCardContent = (
]) &&
isCardImage(value.product) &&
isQuantity(value.purchaseQuantity) &&
isText(value.requirementText) &&
isOneTalkRenderedCardText(value.requirementText) &&
isIdentifier(value.inquiryReference) &&
Array.isArray(value.actions) &&
value.actions.length <= MAX_ITEMS &&
@@ -203,24 +208,24 @@ export const isOneTalkRenderedCardContent = (
hasExactKeys(value, [
"version",
"kind",
"storeImageUrl",
"product",
"priceDisplay",
"minimumOrder",
"serviceBadges",
]) &&
isNullableImageUrl(value.storeImageUrl) &&
isPlainRecord(product) &&
hasExactKeys(product, ["imageUrl", "title", "sourceUrl", "productId"]) &&
isNullableImageUrl(product.imageUrl) &&
isText(product.title) &&
isOneTalkRenderedCardImageUrl(product.sourceUrl) &&
isIdentifier(product.productId) &&
isText(value.priceDisplay) &&
isOneTalkRenderedCardText(product.title) &&
isOneTalkProductReference({
sourceUrl: product.sourceUrl,
productId: product.productId,
}) &&
isOneTalkRenderedCardText(value.priceDisplay) &&
isQuantity(value.minimumOrder) &&
Array.isArray(value.serviceBadges) &&
value.serviceBadges.length <= MAX_ITEMS &&
value.serviceBadges.every((badge) => isText(badge))
value.serviceBadges.every((badge) => isOneTalkRenderedCardText(badge))
);
}
if (value.kind === "rendered_order") {
@@ -240,7 +245,7 @@ export const isOneTalkRenderedCardContent = (
"delivery",
"action",
]) &&
isText(value.title) &&
isOneTalkRenderedCardText(value.title) &&
Array.isArray(value.products) &&
value.products.length <= MAX_ITEMS &&
value.products.every(isCardImage) &&
@@ -248,20 +253,21 @@ export const isOneTalkRenderedCardContent = (
isPlainRecord(status) &&
hasExactKeys(status, ["code", "text"]) &&
(status.code === null || isIdentifier(status.code)) &&
isText(status.text) &&
isOneTalkRenderedCardText(status.text) &&
isPlainRecord(payment) &&
hasExactKeys(payment, ["totalDisplay", "discountDisplay"]) &&
isText(payment.totalDisplay) &&
(payment.discountDisplay === null || isText(payment.discountDisplay)) &&
isOneTalkRenderedCardText(payment.totalDisplay) &&
(payment.discountDisplay === null ||
isOneTalkRenderedCardText(payment.discountDisplay)) &&
isPlainRecord(delivery) &&
hasExactKeys(delivery, ["shippingAddress", "methodLabel", "dateLabel"]) &&
isText(delivery.shippingAddress) &&
(delivery.methodLabel === null || isText(delivery.methodLabel)) &&
(delivery.dateLabel === null || isText(delivery.dateLabel)) &&
isOneTalkRenderedCardText(delivery.shippingAddress) &&
(delivery.methodLabel === null || isOneTalkRenderedCardText(delivery.methodLabel)) &&
(delivery.dateLabel === null || isOneTalkRenderedCardText(delivery.dateLabel)) &&
isPlainRecord(action) &&
hasExactKeys(action, ["label", "status"]) &&
(action.label === null || isText(action.label)) &&
(action.status === null || isText(action.status))
(action.label === null || isOneTalkRenderedCardText(action.label)) &&
(action.status === null || isOneTalkRenderedCardText(action.status))
);
}
return false;
@@ -55,7 +55,9 @@ import {
isNextOneTalkConversationsDiscoveredFragment,
isOneTalkMessage,
isOneTalkMessageContent,
isOneTalkProductReference,
isOneTalkRenderedCardContent,
isOneTalkRenderedCardText,
isSameOneTalkRenderedCardContent,
normalizeOneTalkProductUrl,
type OneTalkMindScope,
@@ -1462,6 +1464,63 @@ test("normalizes raw product URLs and only accepts their exact query-free produc
}
});
test("validates exact canonical rendered-product references without store images", () => {
const canonicalReference = {
sourceUrl: productContent.sourceUrl,
productId: productContent.productId,
};
const renderedProduct = {
version: 1 as const,
kind: "rendered_product" as const,
product: {
imageUrl: "https://img.alicdn.com/item.jpg",
title: "Widget",
...canonicalReference,
},
priceDisplay: "US $10.00",
minimumOrder: { value: "1", unit: "piece" },
serviceBadges: ["Fast dispatch"],
};
assert.deepEqual(normalizeOneTalkProductUrl(productContent.sourceUrl), canonicalReference);
assert.equal(isOneTalkProductReference(canonicalReference), true);
assert.equal(isOneTalkRenderedCardContent(renderedProduct), true);
for (const content of [
{ ...renderedProduct, storeImageUrl: null },
{
...renderedProduct,
product: { ...renderedProduct.product, sourceUrl: "https://img.alicdn.com/item.jpg" },
},
{
...renderedProduct,
product: {
...renderedProduct.product,
sourceUrl: `${productContent.sourceUrl}?chatToken=secret`,
},
},
{
...renderedProduct,
product: { ...renderedProduct.product, productId: "1601456609479" },
},
]) {
assert.equal(isOneTalkRenderedCardContent(content), false);
}
for (const reference of [
{ ...canonicalReference, productId: "1601456609479" },
{ ...canonicalReference, sourceUrl: "https://img.alicdn.com/item.jpg" },
{ ...canonicalReference, unexpected: true },
]) {
assert.equal(isOneTalkProductReference(reference), false);
}
});
test("exports the rendered-card text guard with multiline and control-character semantics", () => {
assert.equal(isOneTalkRenderedCardText("Need 100 widgets\nSecond line"), true);
assert.equal(isOneTalkRenderedCardText("Need 100 widgets\u0007Second line"), false);
});
test("rejects legacy image dimensions as exact-shape extras", () => {
for (const [key, value] of [
["width", 1_280],