From c19e709c932fdb8da75e55aa5315cdd328a94c8a Mon Sep 17 00:00:00 2001 From: YBF <47051132+YBFACC@users.noreply.github.com> Date: Tue, 15 Sep 2026 14:29:40 +0800 Subject: [PATCH] fix: add scoped rendered-card ledger readers --- .../frontend/onetalk/contact-profile-sync.md | 4 +- .../frontend/onetalk/durable-sync.md | 74 +++- .../frontend/onetalk/runtime-sync.md | 6 +- .../check.jsonl | 3 + .../design.md | 54 +++ .../implement.jsonl | 3 + .../implement.md | 35 ++ .../prd.md | 76 ++++ .../task.json | 26 ++ .../onetalk/main-page/card-observer/entry.ts | 211 +--------- .../card-observer/react-card-reader.ts | 337 ++++++++++++++++ .../src/onetalk/service-worker/storage.ts | 36 +- .../test/onetalk-page-bridge.test.js | 70 ++++ .../test/onetalk-rendered-card-reader.test.js | 368 ++++++++++++++++++ .../test/onetalk-sync-storage.test.js | 86 ++++ ...026-09-15-onetalk-rendered-card-readers.md | 291 ++++++++++++++ packages/onetalk-contract/src/content.ts | 61 +-- packages/onetalk-contract/src/index.ts | 3 + packages/onetalk-contract/src/product-url.ts | 74 ++++ .../onetalk-contract/src/rendered-cards.ts | 54 +-- .../onetalk-contract/test/contract.test.ts | 59 +++ 21 files changed, 1623 insertions(+), 308 deletions(-) create mode 100644 .trellis/tasks/09-15-onetalk-rendered-card-readers/check.jsonl create mode 100644 .trellis/tasks/09-15-onetalk-rendered-card-readers/design.md create mode 100644 .trellis/tasks/09-15-onetalk-rendered-card-readers/implement.jsonl create mode 100644 .trellis/tasks/09-15-onetalk-rendered-card-readers/implement.md create mode 100644 .trellis/tasks/09-15-onetalk-rendered-card-readers/prd.md create mode 100644 .trellis/tasks/09-15-onetalk-rendered-card-readers/task.json create mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts create mode 100644 apps/chrome-extension/test/onetalk-rendered-card-reader.test.js create mode 100644 docs/superpowers/plans/2026-09-15-onetalk-rendered-card-readers.md create mode 100644 packages/onetalk-contract/src/product-url.ts diff --git a/.trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md b/.trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md index 8a608c1..4df5c1f 100644 --- a/.trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md +++ b/.trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md @@ -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 集成是另行执行的外部检查。 diff --git a/.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md b/.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md index 925ff1e..6df5681 100644 --- a/.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md +++ b/.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md @@ -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; +}; +``` + +`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 diff --git a/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md b/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md index 238bb4a..3610c56 100644 --- a/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md +++ b/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md @@ -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 diff --git a/.trellis/tasks/09-15-onetalk-rendered-card-readers/check.jsonl b/.trellis/tasks/09-15-onetalk-rendered-card-readers/check.jsonl new file mode 100644 index 0000000..fc33f38 --- /dev/null +++ b/.trellis/tasks/09-15-onetalk-rendered-card-readers/check.jsonl @@ -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."} diff --git a/.trellis/tasks/09-15-onetalk-rendered-card-readers/design.md b/.trellis/tasks/09-15-onetalk-rendered-card-readers/design.md new file mode 100644 index 0000000..3899dce --- /dev/null +++ b/.trellis/tasks/09-15-onetalk-rendered-card-readers/design.md @@ -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. diff --git a/.trellis/tasks/09-15-onetalk-rendered-card-readers/implement.jsonl b/.trellis/tasks/09-15-onetalk-rendered-card-readers/implement.jsonl new file mode 100644 index 0000000..1104464 --- /dev/null +++ b/.trellis/tasks/09-15-onetalk-rendered-card-readers/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/09-15-onetalk-rendered-card-readers/implement.md b/.trellis/tasks/09-15-onetalk-rendered-card-readers/implement.md new file mode 100644 index 0000000..328ddb1 --- /dev/null +++ b/.trellis/tasks/09-15-onetalk-rendered-card-readers/implement.md @@ -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。 diff --git a/.trellis/tasks/09-15-onetalk-rendered-card-readers/prd.md b/.trellis/tasks/09-15-onetalk-rendered-card-readers/prd.md new file mode 100644 index 0000000..ba23d96 --- /dev/null +++ b/.trellis/tasks/09-15-onetalk-rendered-card-readers/prd.md @@ -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` 或任何替代字段保留该概念。 diff --git a/.trellis/tasks/09-15-onetalk-rendered-card-readers/task.json b/.trellis/tasks/09-15-onetalk-rendered-card-readers/task.json new file mode 100644 index 0000000..d5f2a25 --- /dev/null +++ b/.trellis/tasks/09-15-onetalk-rendered-card-readers/task.json @@ -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": {} +} \ No newline at end of file diff --git a/apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts index a4bae59..7be2fad 100644 --- a/apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts @@ -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; -}; - -const isRecord = (value: unknown): value is Record => - 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)[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 | 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) => { - 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, @@ -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; diff --git a/apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts new file mode 100644 index 0000000..075a059 --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts @@ -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; +}; +export type OneTalkRenderedCardRead = { + observation: OneTalkRenderedCardObservation; + baseEvidence: OneTalkPageRenderedCardBaseEvidence; +}; +type CardClassification = + | { kind: "order"; data: Record } + | { kind: "inquiry"; data: Record } + | { kind: "product"; data: Record }; + +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)[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) => boolean, +): Record | 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): boolean => + text(data.cardTitle) !== null && + Array.isArray(data.productInfoList ?? data.productList) && + text(data.orderStatusText) !== null && + text(data.shippingAddress) !== null; + +const inquiryTemplate = (data: Record): boolean => + isObjectRecord(data.inquiryCardDTO) && + Array.isArray(data.displayProducts ?? data.products) && + text(data.inquiryContent) !== null && + scalarId(data.inquiryID) !== null; + +const productTemplate = (data: Record): 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): 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) => + [ + [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): 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): 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, + }, + }; +}; diff --git a/apps/chrome-extension/src/onetalk/service-worker/storage.ts b/apps/chrome-extension/src/onetalk/service-worker/storage.ts index f9f9f52..d01c158 100644 --- a/apps/chrome-extension/src/onetalk/service-worker/storage.ts +++ b/apps/chrome-extension/src/onetalk/service-worker/storage.ts @@ -173,7 +173,7 @@ export type OneTalkRenderedCardLedgerStore = { conversationId: string, messageId: string, ) => Promise; - listPending: (channelAccountId?: string) => Promise; + listPending: (channelAccountId: string) => Promise; 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( - 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( + 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]); diff --git a/apps/chrome-extension/test/onetalk-page-bridge.test.js b/apps/chrome-extension/test/onetalk-page-bridge.test.js index b8a7e4a..58844a5 100644 --- a/apps/chrome-extension/test/onetalk-page-bridge.test.js +++ b/apps/chrome-extension/test/onetalk-page-bridge.test.js @@ -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], diff --git a/apps/chrome-extension/test/onetalk-rendered-card-reader.test.js b/apps/chrome-extension/test/onetalk-rendered-card-reader.test.js new file mode 100644 index 0000000..82e458e --- /dev/null +++ b/apps/chrome-extension/test/onetalk-rendered-card-reader.test.js @@ -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); +}); diff --git a/apps/chrome-extension/test/onetalk-sync-storage.test.js b/apps/chrome-extension/test/onetalk-sync-storage.test.js index 650e69c..80de681 100644 --- a/apps/chrome-extension/test/onetalk-sync-storage.test.js +++ b/apps/chrome-extension/test/onetalk-sync-storage.test.js @@ -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); } diff --git a/docs/superpowers/plans/2026-09-15-onetalk-rendered-card-readers.md b/docs/superpowers/plans/2026-09-15-onetalk-rendered-card-readers.md new file mode 100644 index 0000000..e35a042 --- /dev/null +++ b/docs/superpowers/plans/2026-09-15-onetalk-rendered-card-readers.md @@ -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. diff --git a/packages/onetalk-contract/src/content.ts b/packages/onetalk-contract/src/content.ts index 62d90df..823acbd 100644 --- a/packages/onetalk-contract/src/content.ts +++ b/packages/onetalk-contract/src/content.ts @@ -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): 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 消息内容。 */ diff --git a/packages/onetalk-contract/src/index.ts b/packages/onetalk-contract/src/index.ts index fd2b6f4..0b70bb3 100644 --- a/packages/onetalk-contract/src/index.ts +++ b/packages/onetalk-contract/src/index.ts @@ -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"; diff --git a/packages/onetalk-contract/src/product-url.ts b/packages/onetalk-contract/src/product-url.ts new file mode 100644 index 0000000..3456b85 --- /dev/null +++ b/packages/onetalk-contract/src/product-url.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, 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 + ); +}; diff --git a/packages/onetalk-contract/src/rendered-cards.ts b/packages/onetalk-contract/src/rendered-cards.ts index e87f223..43ad507 100644 --- a/packages/onetalk-contract/src/rendered-cards.ts +++ b/packages/onetalk-contract/src/rendered-cards.ts @@ -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, 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; diff --git a/packages/onetalk-contract/test/contract.test.ts b/packages/onetalk-contract/test/contract.test.ts index bf0d6c9..a960b61 100644 --- a/packages/onetalk-contract/test/contract.test.ts +++ b/packages/onetalk-contract/test/contract.test.ts @@ -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],