mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
fix: add scoped rendered-card ledger readers
This commit is contained in:
@@ -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 record;v9→v10 只在统一主 schema 新增 `onetalk_buyer_facts`,保留 profile 与所有既有 OneTalk ledger records/indexes;`hasProfileRecord(account)` 走账号 index count,pending 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 records;v9→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 count,pending 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` 字节上限。
|
||||
- Observer:initial snapshot、可重复的 `syncData` 发布、精确 snapshot/collect payload 校验、targeted direct 查找、群聊/未知目标排除、登录身份、登出/切账号、CRM 客户匹配和头像 URL 校验。
|
||||
- Ledger:pre-v7(`oldVersion < 7`)升级先清空全部旧 OneTalk store 再重建当前 profile 与消息同步状态,v7→v8 升级保留五个既有 store,v8→v9 逐条保留六个 OneTalk store record 并创建 scope index,v9→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。
|
||||
- Ledger:pre-v7(`oldVersion < 7`)升级先清空全部旧 OneTalk store 再重建当前 profile 与消息同步状态,v7→v8 升级保留五个既有 store,v8→v9 逐条保留六个 OneTalk store record 并创建 scope index,v9→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 错误映射和过期回调/页面身份 fence;direct 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 sync,v7→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 sync,v7→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
|
||||
|
||||
- Good:v9 record 不变地升级到 v10,pending 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 collect;history 不触发。profile 相同 fingerprint 在 coordinator 的唯一 durable read 后静默结束,不能通过 MAIN `seen`、时间水位或 timer 再建第二去重状态。
|
||||
- `oldVersion < 7` 的数据库先执行全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实;v8→v9 只新增 scope index、逐条保留 records;v9→v10 只在统一主 schema 新增 buyer facts store 并保留既有 stores、records 与 indexes),再重新采集 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、逐条保留 records;v9→v10 只新增 rendered-card scope indexes 与 buyer facts store、逐条保留既有 records/indexes),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。v9 profile pending flush 只读取 `[channelAccountId, pending.observedAtMs]` index;v10 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 共用该 normalizer;rendered-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.
|
||||
@@ -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 消息内容。 */
|
||||
|
||||
@@ -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
|
||||
);
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user