From e31ba30d71587c747c560f5ec3e93be8ea1ce9d5 Mon Sep 17 00:00:00 2001 From: YBF <47051132+YBFACC@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:05:10 +0800 Subject: [PATCH] feat: add React commit collection for rendered cards --- .../frontend/onetalk/runtime-sync.md | 8 +- .../spec/server/backend/service-foundation.md | 12 +- .../check.jsonl | 3 + .../design.md | 64 ++ .../implement.jsonl | 3 + .../implement.md | 32 + .../prd.md | 71 ++ .../task.json | 26 + .../card-observer/dom-card-reader.ts | 103 ++ .../card-observer/dom-card-reader/dom.ts | 245 +++++ .../card-observer/dom-card-reader/index.ts | 27 + .../card-observer/dom-card-reader/inquiry.ts | 65 ++ .../card-observer/dom-card-reader/order.ts | 116 +++ .../card-observer/dom-card-reader/product.ts | 27 + .../onetalk/main-page/card-observer/entry.ts | 142 +-- .../card-observer/react-card-reader.ts | 337 ------- .../src/onetalk/page-bridge/main.ts | 14 +- .../src/onetalk/page-bridge/model.ts | 51 +- .../service-worker/page-runtime-host.ts | 2 +- .../rendered-card-coordinator.ts | 27 +- .../src/onetalk/service-worker/storage.ts | 22 +- .../test/onetalk-page-bridge.test.js | 85 +- .../onetalk-rendered-card-coordinator.test.js | 30 +- .../onetalk-rendered-card-observer.test.js | 599 +++-------- .../test/onetalk-rendered-card-reader.test.js | 657 ++++++------ .../test/onetalk-sync-storage.test.js | 14 +- apps/server/drizzle/0015_wild_toad_men.sql | 4 + .../drizzle/0016_optimal_imperial_guard.sql | 2 + apps/server/drizzle/meta/0015_snapshot.json | 951 ++++++++++++++++++ apps/server/drizzle/meta/0016_snapshot.json | 951 ++++++++++++++++++ apps/server/drizzle/meta/_journal.json | 14 + apps/server/src/database/schema/onetalk.ts | 16 +- apps/server/src/onetalk/read-projection.ts | 16 + .../server/src/onetalk/rendered-card-model.ts | 1 - .../src/onetalk/rendered-card-repository.ts | 12 +- .../src/onetalk/rendered-card-service.ts | 6 +- ...alk-business-card-marker-migration.test.ts | 13 +- ...onetalk-image-dimensions-migration.test.ts | 15 +- .../onetalk-product-content-migration.test.ts | 13 +- apps/server/test/onetalk-read-domain.test.ts | 13 + .../test/onetalk-rendered-card-flow.test.ts | 23 +- .../onetalk-rendered-card-migration.test.ts | 47 +- .../onetalk-rendered-card-repository.test.ts | 68 +- apps/server/test/onetalk-websocket.test.ts | 39 +- ...6-onetalk-dom-secondary-card-collection.md | 186 ++++ packages/onetalk-contract/src/content.ts | 16 +- packages/onetalk-contract/src/index.ts | 3 + .../onetalk-contract/src/rendered-cards.ts | 86 +- .../onetalk-contract/test/contract.test.ts | 118 ++- 49 files changed, 3828 insertions(+), 1567 deletions(-) create mode 100644 .trellis/tasks/09-16-onetalk-dom-secondary-card-collection/check.jsonl create mode 100644 .trellis/tasks/09-16-onetalk-dom-secondary-card-collection/design.md create mode 100644 .trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.jsonl create mode 100644 .trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.md create mode 100644 .trellis/tasks/09-16-onetalk-dom-secondary-card-collection/prd.md create mode 100644 .trellis/tasks/09-16-onetalk-dom-secondary-card-collection/task.json create mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/dom.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/index.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/inquiry.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/order.ts create mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/product.ts delete mode 100644 apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts create mode 100644 apps/server/drizzle/0015_wild_toad_men.sql create mode 100644 apps/server/drizzle/0016_optimal_imperial_guard.sql create mode 100644 apps/server/drizzle/meta/0015_snapshot.json create mode 100644 apps/server/drizzle/meta/0016_snapshot.json create mode 100644 docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md diff --git a/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md b/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md index 3cca858..abe1588 100644 --- a/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md +++ b/.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md @@ -94,7 +94,7 @@ Bright WebSocket - `message-observer/send-observation.ts` 保持发送 pending、候选 ID、timeout 和 settle 的唯一 owner;命令侧通过 execute/executeImage/executeFile 使用。`image-send.ts` 保持原生媒体上传拦截器和关联状态,不复制或搬迁模块级状态容器。 - profile observer 可将 `syncData` 的 direct profiles 再次发布;它不维护 `seen`、baseline 或其他上传资格状态。snapshot 只读取当前已加载资料,collect 只读取一个明确 conversationId;两者都复用已安装 observer,命令只返回统计,不能再二次 publish。 - 新 installer、publisher 和 dispatcher 不拥有 Map、Set、timer、账本或连接。消息投递、profile/buyer 账本、ACK、页面请求关联及 Bright 生命周期继续归各原 Service Worker owner。 -- `card-observer/entry.ts` 以 MutationObserver 批次作为 rendered-card 的唯一主动触发。`.message-item-wrapper` 只表示 React 已挂载,不表示 Fiber/template 已齐备:首次扫描及该 wrapper 子树的 `childList`、attributes 或 text 变化都必须立即重新读取;只有 reader 成功产生 exact 白名单 observation 后按 `conversationId + messageId + fingerprint` 去重。reader 返回 `null` 不写 bridge/ledger,并等待下一次该 wrapper 的 DOM 变化;不得全局轮询、用 DOM 文案兜底或把 raw Fiber/props 跨 MAIN。 +- `card-observer/entry.ts` 以 MutationObserver 批次作为 rendered-card 的唯一主动触发。reader 以当前 single conversation 加 card/wrapper `data-expinfo` 的严格消息 identity 为键;任一已经出现的会话候选字段空、非法或冲突时 fail closed,不能忽略后回退。它只发布无 `kind` 的 `{ version: 1, ...namedFields }`,并只可由一个可见锚点分派内部字段解析器:订单=`订单状态`,询盘=`详细需求`,商品=`最小订购量`/`Minimum order`。零个或多个锚点命中时不发布;这个内部解析分派绝不进入 payload、bridge 或数据库。所有图片是从已验证 owner 取第一张安全 URL 的单值 `image`。订单输出可见“订单”标题、商品图及已审核 `productCount`、`status`、`total`、`deliveryDate`、`shippingAddress`;询盘从 `.description-container` 读取保留换行的 `innerText`,输出 `title`、`image`、`purchaseQuantity` 与 `reference`;商品在同一 `.dx-event-node` owner 内以三段 MOQ label/value/unit 定位标题、价格和安全图片,MOQ 输出组合字符串。`images`、`productImages`、`attributes`、`promotionTexts` 与对象 MOQ 不是合法公开字段。模板 ID 和路径只可用于调试/fixture,绝不能作为采集准入或字段映射门槛。读取时排除 `template`、`hidden`、`aria-hidden`、`display:none`、`visibility:hidden` 祖先,图片必须通过无 query/fragment 的安全 URL guard;未知或不安全单字段省略,动作节点不投影。成功后按 `conversationId + messageId + fingerprint` 去重;不得按 cardType/messageType/template 推断业务类型,也不得全局轮询、使用 React/Fiber/props 或把原始 metadata 跨 MAIN。 #### Validation & Error Matrix @@ -109,8 +109,8 @@ Bright WebSocket | 未知 action | rejected_before_send/invalid_request,不访问 SDK 或 snapshot | | send 的 SDK 本地接受、超时或歧义 | 保持既有发送 SOP,不增加重试或假成功 | | 历史命令返回 batch | 使用同一 publisher,不新建发送关联器或上报管线 | -| rendered-card wrapper 先挂载、后补齐 Fiber/template | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 | -| rendered-card reader 仍无完整白名单模板 | 不发布、不写 ledger;仅等待该 wrapper 的下一次子树变化 | +| rendered-card 已挂载后文本/图片或 metadata 才补齐 | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 | +| identity、可见快照或安全图片 URL 不完整 | 不发布、不写 ledger;仅等待该 card 的下一次子树变化 | #### Good / Base / Bad Cases @@ -122,7 +122,7 @@ Bright WebSocket - `onetalk-page-flow-boundaries.test.js` 覆盖 publisher 双消费顺序、诊断-only/空批次、snapshot/collect exact payload、targeted collect、未知动作与实际页面入口安装顺序。 - `onetalk-send-page.test.js` 保留 send/history 的输入、结果、tooltip 和发布行为;`onetalk-send-observation.test.js` 与媒体测试保留唯一确认、timeout/异常及不重试断言。 -- `onetalk-rendered-card-observer.test.js` 覆盖 wrapper 首次读取失败、后续子树/属性/文本变化后立即重新读取并只生成一次 approved observation。 +- `onetalk-rendered-card-reader.test.js` 覆盖 card/wrapper identity、三个单锚点解析器、零/多锚点 fail-closed、嵌套商品字段、询盘正文换行、隐藏/template 文本排除和安全图片 URL;`onetalk-rendered-card-observer.test.js` 覆盖后续子树/属性/文本变化后立即重新读取并只生成一次 approved observation。 - 对构建后的 MAIN IIFE 执行 `scripts/verify-release-bundle.mjs`,验证实际 shared observation 驱动消息上报及单次发送确认;这属于无网络 Node VM 冒烟,真实 Chromium/OneTalk/Bright 联调需单独标记。 #### Wrong vs Correct diff --git a/.trellis/spec/server/backend/service-foundation.md b/.trellis/spec/server/backend/service-foundation.md index 8d1f6b3..67bdbc4 100644 --- a/.trellis/spec/server/backend/service-foundation.md +++ b/.trellis/spec/server/backend/service-foundation.md @@ -387,7 +387,7 @@ toOneTalkCenterMessage( ): OneTalkCenterMessage; ``` -`OneTalkCenterMessageContent` 的 `inquiry`、`product`、`order` 分支分别可增加已批准的展示字段;`OneTalkMessageContent` 与 `OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。 +`OneTalkCenterMessageContent` 的 legacy `inquiry`、`product`、`order` 分支可保留已批准的历史展示字段;新的 type-agnostic named supplement 通过 `OneTalkCenterMessage.renderedCard?: OneTalkRenderedCard` 公开。`OneTalkMessageContent` 与 `OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。 ### 3. Contracts @@ -396,6 +396,7 @@ toOneTalkCenterMessage( - product 的 `sourceUrl` 与 `productId` 永远来自基础消息,且必须与补全中的身份一致。补全 kind 或 product 身份不一致、补全形状非法时显式失败,不能丢弃补全、猜测类型或发布部分内容。 - `projectCenterMessage`、rendered-card update、live/history sync 和 send-confirmation 都只把基础 message 与 optional supplement 传给 `toOneTalkCenterMessage`;不得在调用点以 `renderedCardContent ?? message.content` 替换内容。 - 补全先到时,后续单条或批量 accepted 基础消息都必须按同一 `channelAccountId + conversationId + messageId` 受限读取已有 supplement,并在 `message.created` 投影前带回。提交、guard、ACK 和 publish 顺序保持不变。 +- 新写入的 `renderedCard` 是 type-agnostic exact shape `{ version: 1, ...namedFields }`,没有 `kind`、`actions`、通用 `fields`、裸 `texts`/`imageUrls` 或 DOM/template metadata;商品 MOQ 只用 `{ value, unit }`。它只能作为顶层 `renderedCard` 附加,绝不替换或补写基础 `content`,也不要求基础消息先存在。legacy typed supplement 仅为既有事实的读取兼容保留。 ### 4. Validation & Error Matrix @@ -406,18 +407,19 @@ toOneTalkCenterMessage( | rendered product 的 URL 或 product ID 不一致 | 投影失败,不以补全覆盖基础身份 | | unknown key、错误嵌套或 ledger 字段进入 Center content | strict guard 拒绝 | | supplement 先到、base message 后由 batch live accepted | repository 带回 supplement;`message.created` 使用基础 kind 加批准字段 | +| generic supplement 先到、base message 后到 | repository 带回同一复合 identity 的 snapshot;`message.created.content` 不变,仅有顶层 `renderedCard` | | duplicate/conflict supplement 或补全写失败 | 保持既有 ACK/冲突语义;不改写基础消息事实 | ### 5. Good / Base / Bad Cases -- Good:HTTP、`message.created` 和 `message.updated` 都调用同一个投影,订单公开为 `kind: "order"` 并可带 `status`/`payment`,内部 `rendered_order` 不离开服务端边界。 +- Good:HTTP、`message.created` 和 `message.updated` 都调用同一个投影;generic snapshot 只作为顶层 `renderedCard`,基础 `content.kind` 不变。 - Base:基础 message 先到且没有 supplement 时,后续读取保留原始 shape;补全到达后只发一次同一基础 kind 的 `message.updated`。 - Bad:repository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。 ### 6. Tests Required -- Contract:三种基础 kind 的无补全 exact shape、补全后的批准可选字段、unknown key/错误嵌套拒绝,以及 supplement kind/product identity 不一致拒绝。 -- Read/WebSocket:HTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖补全后到、补全先到后单条 live、补全先到后 batch live 和 send confirmation,断言公开 kind 永不为 `rendered_*`。 +- Contract:generic `renderedCard` 的 exact shape、unknown key/错误嵌套拒绝,以及 legacy supplement kind/product identity 不一致拒绝。 +- Read/WebSocket:HTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖 generic supplement 后到、先到后单条 live、先到后 batch live,断言基础 content 不变而仅顶层 snapshot 出现。 - Repository:batch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。 ### 7. Wrong vs Correct @@ -426,7 +428,7 @@ toOneTalkCenterMessage( // Wrong: internal supplement replaces the public discriminated content. toOneTalkCenterMessage({ ...message, content: renderedCardContent ?? message.content }); -// Correct: the sole projection validates and merges two separate facts. +// Correct: the sole projection validates two separate facts and adds generic DOM data beside content. toOneTalkCenterMessage(message, renderedCardContent); ``` diff --git a/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/check.jsonl b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/check.jsonl new file mode 100644 index 0000000..289f56b --- /dev/null +++ b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/check.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/prd.md","reason":"Acceptance criteria and explicit exclusions for review."} +{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/design.md","reason":"Data flow, ownership, migration, and fail-closed design review."} +{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.md","reason":"Required verification and rollback checks."} diff --git a/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/design.md b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/design.md new file mode 100644 index 0000000..bf4da88 --- /dev/null +++ b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/design.md @@ -0,0 +1,64 @@ +# OneTalk DOM 二次卡片采集重构设计 + +## 目标与边界 + +二次采集以 DOM-only reader 替代 React/Fiber reader,并独立保存不带 `kind` 的命名补全字段。它不判断也不覆盖基础消息 `order`、`inquiry`、`product` 或其它 kind;补全可先于基础消息保存,后续读取或实时发布再由完整复合 identity 关联。 + +`renderedCard` 是 exact-shape、versioned 公共值,至少包含一个已批准字段,但绝不含 `kind`、模板 ID、DOM path、class、`data-expinfo`、HTML、style、React props、URL query/fragment 或动作参数: + +```ts +type OneTalkRenderedCard = { + version: 1; + title?: string; + image?: string; + price?: string; + minimumOrder?: string; + purchaseQuantity?: string; + requirementText?: string; + reference?: string; + productCount?: { display: string; value: number | null }; + status?: string; + total?: string; + deliveryDate?: string; + shippingAddress?: string; +}; +``` + +字段是可选的,因不同模板本来显示不同内容;空对象、未知键、无安全字段或不符合模板映射的卡 fail closed。图片仍须为 approved host、HTTPS、无 query/fragment 的 URL。 + +## 数据流与所有权 + +```text +DOM card + selected conversation + -> structure-based field reader in MAIN + -> generic named renderedCard observation + -> page bridge -> SW ledger -> rendered.card.observed -> server JSONB + -> base-message read/create/update by composite identity + -> OneTalkCenterMessage.renderedCard +``` + +MAIN 是 DOM 结构和可见节点读取的唯一 owner;shared contract 拥有 `OneTalkRenderedCard` exact shape、URL/text guards、fingerprint/clone/equality;server repository 拥有独立事实的 first-content-wins/conflict;read projection 是唯一将 base message 与 generic supplement 组合为 Mind 输出的 owner。 + +## 严格 identity + +读取前后必须是同一 single selected conversation。卡和最近 wrapper 的 `data-expinfo` 都必须解码,messageId 必须合法且相等;任一已出现 `cid`/`conversationCode`/`externalConversationId` 为空、非法或冲突即拒绝。卡 metadata 缺 conversation ID 时,只能用读前后一致的 selected conversation 补足。任何 `cardType`/`messageType` 只可作为诊断与模板采样证据,永不进入 payload 或作为业务类型。 + +## 结构化命名字段 + +模板 ID 和路径仅可用于运行时调试或 fixture 采样,绝不能作为采集准入、业务类型或字段映射的门槛。reader 以恰好一个可见文本锚点选择内部字段解析器:订单=`订单状态`,询盘=`详细需求`,商品=`最小订购量`/`Minimum order`;任一类别锚点重复、零个或多个类别同时命中时 fail closed。该分发不产生或传输业务 `kind`。所有卡只从已验证图片 owner 取第一张安全图片为单值 `image`。询盘从可见 `.description-container` 读取保留换行的 `requirementText`,并将商品、采购数量与 ID 分别投影为 `title/image`、`purchaseQuantity`、`reference`。订单读取可见“订单”为 `title`;`5 products` 这类直接数量展示从同一局部容器的前置兄弟图片区取代表图,绝不扫描整卡而误取顶部箭头图标;再映射已审核的 `productCount`、`status`、`total`、`deliveryDate`、`shippingAddress`。其它未识别行省略。商品在同一 `.dx-event-node` owner 内以三段 normalized MOQ label/value/unit 锚定,读取其所属可见 TextView 序列中的价格、标题和安全图片;MOQ 公开为原样组合字符串(如 `500 Pieces`)。动作、促销/服务标签与数组字段不投影。 + +读取每个 DOM text/image 前都排除 `template`、`hidden`、`aria-hidden=true`、`display:none`、`visibility:hidden` 祖先。无法识别或不安全的单个字段省略;没有任何可投影字段才 fail closed。图片仍须为安全 URL,且不安全可选图片组不能阻断已验证的文本字段。 + +## Contract、数据库与公开组合 + +`OneTalkRenderedCardContent` 保留 legacy `rendered_inquiry/product/order` 仅为已存数据的读取兼容,新生产 reader 只写无 kind 的 `OneTalkRenderedCard`。页面 bridge 解码、wire 与 ledger 的 observation content 只允许该 generic object,typed 历史内容不可重发。`OneTalkCenterMessage.renderedCard?: OneTalkRenderedCard` 同样无 kind,基础 `content` 原样保留。 + +repository 继续独立 insert/duplicate/conflict;基础消息先到或补全先到均按 `[channelAccountId, conversationId, messageId]` 关联。legacy typed supplement 保留旧 public projection;新 generic supplement 只附加顶层 `renderedCard`。本次不把 `send.result` 扩展为补全 transport。 + +数据库 migration 保留 0015 的四列删除,替换 0016 中的 temporary `rendered_dom_card` check:兼容 legacy typed kinds,或允许无 `kind` 且 `version=1` 的 generic object。迁移不得 DELETE、TRUNCATE、回填或重建表。 + +## 验证与回滚 + +fixture 必须覆盖未知模板 ID 仍可按已识别 DOM 结构采集、无模板 metadata 与 `view-name="TextView"` 的订单/商品结构、空结构拒绝、hidden nodes、identity 非法/冲突、unsafe images、DOM mutation 重读、generic supplement/base 先后顺序、legacy read compatibility 与 exact Center decoder。真实 Chrome 仅做无点击/无刷新验证,且须加载本次构建才能宣称 bridge/ACK/DB runtime 通过。 + +generic rows 出现后,回滚 schema check 前由用户在任务外处理 generic rows;本任务不自动删除 supplement。 diff --git a/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.jsonl b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.jsonl new file mode 100644 index 0000000..e46d1d1 --- /dev/null +++ b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.jsonl @@ -0,0 +1,3 @@ +{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/prd.md","reason":"Approved scope, retention choices, card types, and acceptance criteria."} +{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/design.md","reason":"DOM reader, bridge, ledger, database, and public-projection boundaries."} +{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Required cross-layer contract and validation guidance."} diff --git a/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.md b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.md new file mode 100644 index 0000000..18dbf03 --- /dev/null +++ b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.md @@ -0,0 +1,32 @@ +# 执行清单:OneTalk DOM 二次卡片采集重构 + +## 修改范围 + +- Replace current uncommitted `rendered_dom_card` contract/reader/tests with no-kind named `OneTalkRenderedCard` +- Modify generic contract, CenterMessage decoder/export, DOM reader/observer, bridge, ledger and focused tests +- Modify server schema, migration 0016, repository/read projection and server tests +- Delete obsolete React reader and retain only legacy typed read compatibility + +## 顺序 + +1. 把 contract tests 改为无 `kind` 的 exact named object;空、unknown key、裸 `texts[]`、DOM/template metadata 都必须拒绝。 +2. 写结构 fixture:未知订单模板 `3829708538501` 与无模板 metadata 的卡都必须按可见结构产生 approved named values,不从 cardType/messageType/template ID 决定 mapping;空结构仍拒绝。先运行 reader tests 观察旧 template gate 失败。 +3. 实现三个 structure-based reader:strict identity -> 单一可见锚点分发(订单=`订单状态`、询盘=`详细需求`、商品=`最小订购量`/`Minimum order`)-> safe named fields。零个或多个锚点命中时 fail closed;分发不产生 `kind`。订单输出可见标题、单张图片和已审核 label/value;询盘使用单值 `image`、`purchaseQuantity`、保留换行的 `.description-container` 和 `reference`;商品输出单值 `image`、标题、价格和字符串 MOQ。删除数组图片、`attributes`、`promotionTexts`、对象 MOQ、template registry 和 generic TreeWalker list projection;未知或不安全单字段省略,动作节点不投影。 +4. 改 bridge/ledger/wire 到 no-kind generic object,保留 durable-first/ACK/composite idempotency,删除 reader 内任何 `rendered_dom_card` 路径。 +5. 更新 CenterMessage `renderedCard` 和 server projection:generic supplement 只作顶层字段,base `content.kind` 不变;保留 typed historical supplement 分支。 +6. 改 0016/schema/migration tests:不允许 `rendered_dom_card`,接受 legacy typed 或无 kind/versioned generic object;0015 只 DROP 四列不变。 +7. 跑 contract/extension/server full tests、typecheck、db:check、root build、format/diff and source-boundary searches。真实 Chrome 仅在加载 current build 后验证 product/inquiry/order mappings 与 bridge/ACK/DB。 + +## 验证命令 + +```text +pnpm --filter @trade-message-center/onetalk-contract test +pnpm --filter @trade-message-center/chrome-extension test +pnpm --filter @trade-message-center/chrome-extension typecheck +pnpm --filter @trade-message-center/server test +pnpm --filter @trade-message-center/server typecheck +pnpm --filter @trade-message-center/server db:check +pnpm build +git diff --check +rg -n 'rendered_dom_card|texts:|cardType|messageType|React|Fiber|baseEvidence|setInterval' apps/chrome-extension/src/onetalk/main-page/card-observer apps/chrome-extension/src/onetalk/page-bridge apps/chrome-extension/src/onetalk/service-worker packages/onetalk-contract/src apps/server/src +``` diff --git a/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/prd.md b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/prd.md new file mode 100644 index 0000000..9077d54 --- /dev/null +++ b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/prd.md @@ -0,0 +1,71 @@ +# OneTalk DOM 二次卡片采集重构 + +## Goal + +删除现有 OneTalk 二次卡片采集实现,以已渲染 DOM 为唯一生产源,稳定采集所有具严格消息归属的卡片可公开字段,并沿既有受控 bridge、ledger、Bright supplement 入库链路独立发送。二次数据不决定业务类型;Mind 在读取或发布基础消息时按同一复合身份关联这些字段,基础 kind 保持为第一次采集的事实。 + +## Confirmed facts + +- 用户已在真实 OneTalk 页面只读展示四类卡。每一类都可从 `.session-rich-content.card` 定位最近 `.message-item-wrapper`;卡内首个 `[data-expinfo]` 与 wrapper 的 `data-expinfo` 都有相同的 `messageId`,未观察到冲突或缺失。 +- 历史样本曾显示询盘、商品、订单和报价;最新真实只读样本证明 card metadata 不稳定:报价样本有 `cardType=8`,用户指定订单卡没有 `cardType`,只呈现 `messageType=1` 和通用 `TextView` 模板。卡内与 wrapper 的 message ID 一致,但当前样本两边都没有会话 ID。 +- TradeBridge 的 `oneTalkProductCardFromElement()` 已验证可复用的消息归属模式:从卡内和 wrapper 读取 `data-expinfo`,消息或会话 ID 冲突时丢弃。其商品 URL gate 仅适合商品字段投影,不能用于询盘、订单或报价。 +- 当前仓库仍有旧的 rendered-card reader task;本任务以用户最新确认的 DOM-only 范围为准,不能重新引入 React/Fiber root traversal、固定扫描预算、MutationObserver/timer fallback 或依赖原始 React props 的采集。 +- 旧 MAIN observer 依赖 `react-card-reader.ts` 从 Fiber 提取卡片字段以及 `direction`、`sentAtMs` base evidence;page bridge 与 Service Worker ledger 当前强制这些 evidence 与 observation 一一对应。服务端 `rendered.card.observed` wire 和独立 supplement repository 并不携带它们,且补全的 durable-first commit 不等待基础消息 ACK。 +- 当前数据库 `onetalk_rendered_card_content` 以复合消息身份存储补全 JSONB;旧 kind check 仅接受 typed supplement,须兼容扩展为新的 type-agnostic snapshot。 +- `onetalk_rendered_card_content` 的复合主键(账号、会话、消息)、`rendered_card_content`、`rendered_card_content_fingerprint` 和 `rendered_card_observed_at_ms` 是本任务保留的持久化形状。页面观察时间和指纹服务于重传匹配/幂等;它们不出现在 Mind-facing content。 +- 用户确认保留 `conflict_count`,删除 `first_confirmed_at`、`last_observed_at`、`last_conflicting_fingerprint`、`last_conflict_observed_at_ms`。`conflict_count` 在同一复合身份收到不同内容快照时递增;删除 `last_conflicting_*` 后仍保留首内容不可覆盖、conflict ACK 和总冲突计数,但不再持久化最近冲突的指纹或时间。 +- 用户将自行删除历史 supplement。本任务不包含历史数据删除、重算或迁移回填;schema migration 不得执行 `DELETE`、`TRUNCATE` 或重建表。 +- 用户确认二次采集数据完全脱离类型:它可以在基础消息尚未入账时独立持久化。后续读取或发布基础消息时,按复合 identity 关联已存的二次快照;第一次采集的基础 kind 是唯一类型事实源。 +- 用户确认二次补全 JSON 不得有 `kind`;`rendered_dom_card` 与裸 `texts[]`/`imageUrls[]` 都不适合作为公开业务数据。二次数据使用单值 `image`,不允许 `images`/`productImages` 数组;询盘采购数量必须是 `purchaseQuantity` 字符串,不允许 `attributes`;商品 MOQ 必须是原样组合字符串 `minimumOrder`,不允许对象;不允许 `promotionTexts`。商品标题只可在 MOQ 锚定的同一 owner 内相对已识别价格读取。 +- 已证实的 OneTalk 实时样本只有通用 `dx-card-wrapper`、`tpl-wrapper`、`TextView` 等结构;当前没有 `title`/`price` 等稳定 class 或 attribute 的运行时证据。新增命名字段前必须对每种真实模板采集并审核其 selector/class/path 证据;无稳定证据时不能把任意 TextView 或第 N 个文本猜成 title。 +- 商品卡的运行时 root-relative 证据已校正:`data-tpl-id=4305043349523` 的 `tpl-wrapper` root 下,title=`0.1.2.0.0`、price=`0.1.3.1.0.0.0`、minimumOrder=`0.1.4.0.0`/`0.1.4.1.0`/`0.1.4.2.0`、promotion=`0.1.5.1.0`、images=`0.1.0.0.0.0`/`0.1.1.0.0.0`/`0.1.5.0.0.0`。此前采样路径按 card 根记录,传给 tpl root 时少了首层 `0`,会 fail closed;路径只用于 MAIN DOM reader 内部选择,不能持久化或跨 bridge。 +- 用户切换的询盘卡提供第二份模板证据:`data-tpl-id=3593828979407`。`.list-item` 的 item image/text 子树位于 `2.0.0.0` 与 `2.0.0.1`;`.description-container` 是需求正文的稳定 DOM owner,位于 `3.0.3.0.0`;两个 `.dx-event-node` 操作位于 `4.0` 与 `4.1`。其它 TextView 字段的语义尚未从脱敏结构确定,不能按路径猜为 title、数量或询盘号。 +- 询盘卡 root-relative 映射为:title=`0.2.0.0.1.0.0`、item image owner=`0.2.0.0`、purchaseQuantity=`0.3.0.0`、requirementText=`0.3.0.3.0.0`、reference=`0.3.0.6.1.0`。该模板只在可见 `详细需求` 表明询盘结构时,输出无 `kind` 的 `{ title?, image?, purchaseQuantity?, requirementText?, reference? }`;不上传静态“询盘”、`详细需求`、`采购数量`、`ID:`等 UI label 或操作参数。 +- 订单卡 root-relative 映射为:`data-tpl-id=3620167990752`;title=`0.0.0.0`(可见“订单”)、`5 products` 数量展示的同级前置图片 owner(不含顶部订单图标)、productCount=`0.0.2.1.1.0.0`;status/total/deliveryDate/shippingAddress=`0.0.3.0.1.0`/`0.0.3.2.1.0`/`0.0.3.5.1.0`/`0.0.3.8.1.0`。该模板输出无 `kind` 的 `{ title?, image?, productCount?, status?, total?, deliveryDate?, shippingAddress? }`,不上传未识别 label/value 行或动作参数。 + +## Requirements + +### R1. Hard-delete legacy collection production path + +- 删除被 DOM reader 取代的旧二次采集生产路径、其专属分类/读取逻辑及不再可达的诊断、测试辅助和配置;不得保留双写、运行时 feature flag 或静默 fallback。 +- 不改变基础消息采集、历史同步、授权、bridge envelope、ledger ACK、补全独立提交、冲突和发布语义,除非当前代码证据证明 DOM 采集契约必须做兼容变更。 + +### R2. Shared strict DOM identity and type-agnostic projection + +- 从 `.session-rich-content.card` 的卡内 `[data-expinfo]` 和最近 `.message-item-wrapper[data-expinfo]` 读取消息归属。两个位置都存在时,规范化后的 `messageId` 必须相等;会话 ID 同时存在时也必须相等;缺失、无效或冲突时不得发送。 +- 所有具合法 identity 的卡共享上述归属解析器。二次采集投影为无 `kind` 的 type-agnostic named card fields:键只能来自已验证的 DOM selector/class/template 映射,值是受限文本、数量、展示价格、促销文本或安全图片;不含 `data-expinfo` 原文、样式、HTML、React props、URL query/fragment、动作参数或业务分类。 +- 二次采集不得从卡自身 `cardType`、`messageType` 或模板 ID 推断或输出 `order`、`inquiry`、`product` 或任何其它 kind;reader 仅可由单个可见锚点内部选择字段解析器:`订单状态`、`详细需求`、`最小订购量`/`Minimum order`。零个或多个锚点同时命中都不发送,合法 identity 即可独立写 ledger 和发送 frame,基础消息尚未入账不是门槛。 +- 新内容走当前 `rendered.card.observed` 发送与 ACK 链路,且每次 observation 按当前复合消息身份和内容指纹保持幂等。 +- 为完成旧采集硬删除,移除只为 Fiber 提供的 MAIN `baseEvidence` 依赖;DOM reader 不得从 CSS 方向、页面账户、原始 metadata 或其他猜测来源伪造 `direction` / `sentAtMs`。该字段不进入 server wire,且不能成为独立 supplement 发送门槛。 + +### R3. Persist independent snapshots and compose them with base messages + +- 共享 contract、数据库补全 JSON 和 Mind-facing `OneTalkCenterMessage` 增加同一个可选 `renderedCard` 字段对象;它没有 `kind`,使用命名字段而不是裸文本/图片列表,位于基础消息旁,绝不覆盖基础 content 的 kind 或字段。 +- 服务器按复合 identity 独立保存/重传/ACK 快照;当 supplement 或基础消息任一方先到达时,之后的基础消息读取与 `message.created`/`message.updated` 投影都组合 `renderedCard`。不需要基础消息 ACK 或本地 first-candidate 才能提交二次数据。 +- schema migration 删除已批准四列,并将既有 JSONB kind check 兼容扩展为 legacy typed kinds 与无 `kind` 的 version-1 named supplement。新生产路径只生成无 kind 的 type-agnostic object;迁移不得执行 `DELETE`、`TRUNCATE` 或表重建。 + +### R4. Regression and verification + +- 为共享 identity resolver、通用 DOM snapshot、身份冲突/缺失、supplement/基础消息双向先后顺序和公开组合建立回归测试;证明旧采集不再可运行。 +- 按扩展、contract、server 的变更范围运行定向测试、类型检查与构建;对真实 Chrome 只做无点击、无刷新验证,并将运行时证据与本地/数据库验证分开报告。 + +## Out of scope + +- 不操作 OneTalk 页面,不发送消息,不主动同步,也不读取或输出凭据、原始 `data-expinfo`、完整 URL query 或用户隐私内容。 +- 不修改基础消息身份、历史 cursor、授权或正常文本/图片/文件消息采集。 +- 未经确认不删除或重写已经持久化的 rendered-card supplement 历史数据。 + +## Acceptance criteria + +- [ ] 旧二次采集生产路径已被删除,运行时只有 DOM-only 路径能创建 rendered-card observation,且没有双写或 fallback。 +- [ ] 所有具严格 identity 的卡均通过同一个 resolver 创建 type-agnostic observation;任何 card type、message type、模板 ID 或文本位置均不参与业务类型判断;identity 冲突、缺失或无效都不发送。 +- [ ] 二次数据是经证据验证且不含 `kind` 的命名 DOM 字段,包含 `title` 等字段时必有对应 selector/class/template 证据与回归;不存在裸文本数组、raw DOM metadata、React props、敏感 URL query 或动作参数。 +- [ ] 每张已发送卡仅命中订单、询盘、商品三个解析器中的一个;订单、询盘、商品各自由 `订单状态`、`详细需求`、`最小订购量`/`Minimum order` 单独锚定,绝不以多个字段组合猜测类别。 +- [ ] 从独立 supplement 到数据库、基础消息读取与实时发布的关联有测试覆盖:补全先到与基础消息先到都得到同一命名 `renderedCard`;基础内容 kind 保持为第一次采集的事实,二次数据不携带或覆盖 business kind。 +- [ ] 相关测试、类型检查和构建通过;真实 Chrome 只读验证与数据库写入/ACK 验证结论分别记录。 + +## Decisions + +- 二次数据完全 type-agnostic;所有合法 identity 的已渲染卡均可独立记录,无法安全投影的 DOM 值逐字段拒绝而非猜测。 +- 保留 `rendered_card_content_fingerprint`、`rendered_card_observed_at_ms`、`conflict_count`;删除 `first_confirmed_at`、`last_observed_at`、`last_conflicting_fingerprint`、`last_conflict_observed_at_ms`。 +- 历史 supplement 由用户在任务外自行清理;本任务不执行历史数据操作。 diff --git a/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/task.json b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/task.json new file mode 100644 index 0000000..33a0f9d --- /dev/null +++ b/.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/task.json @@ -0,0 +1,26 @@ +{ + "id": "onetalk-dom-secondary-card-collection", + "name": "onetalk-dom-secondary-card-collection", + "title": "OneTalk DOM 二次卡片采集重构", + "description": "删除旧二次采集并以严格 DOM 归属读取订单、询盘和商品卡", + "status": "in_progress", + "dev_type": null, + "scope": null, + "package": null, + "priority": "P2", + "creator": "ybf", + "assignee": "ybf", + "createdAt": "2026-09-16", + "completedAt": null, + "branch": "09-16-onetalk-dom-secondary-card-collection", + "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/dom-card-reader.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader.ts new file mode 100644 index 0000000..139512f --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader.ts @@ -0,0 +1,103 @@ +// 从 OneTalk 已渲染卡片投影严格 identity 绑定的可公开 DOM 快照。 + +import { + createOneTalkRenderedCardContentFingerprint, + isOneTalkRenderedCardIdentifier, + type OneTalkRenderedCardObservation, +} from "@trade-message-center/onetalk-contract"; +import { isObjectRecord } from "../../../lib/guards.ts"; +import { readConversationSelection } from "../page-context.ts"; +import type { OneTalkPageWindow } from "../model.ts"; +import { parseOneTalkDomCard } from "./dom-card-reader/index.ts"; + +type CardIdentity = { messageId: string; conversationId: string | null }; + +const scalarId = (value: unknown): string | null => { + const candidate = + typeof value === "string" + ? value.trim() + : typeof value === "number" && Number.isSafeInteger(value) + ? String(value) + : null; + return candidate && isOneTalkRenderedCardIdentifier(candidate) ? candidate : null; +}; + +const infoFor = (element: Element): Record | null => { + const serialized = element.getAttribute("data-expinfo"); + if (!serialized) return null; + try { + const parsed: unknown = JSON.parse(serialized); + return isObjectRecord(parsed) ? parsed : null; + } catch { + return null; + } +}; + +const identityFor = (info: Record): CardIdentity | null => { + const messageId = scalarId(info.messageId); + const conversationIds: string[] = []; + for (const field of ["cid", "conversationCode", "externalConversationId"] as const) { + if (!Object.hasOwn(info, field)) continue; + const conversationId = scalarId(info[field]); + if (!conversationId) return null; + conversationIds.push(conversationId); + } + if (!messageId || conversationIds.some((value) => value !== conversationIds[0])) return null; + return { messageId, conversationId: conversationIds[0] ?? null }; +}; + +const metadataElementFor = (card: Element): Element | null => + card.matches("[data-expinfo]") ? card : card.querySelector("[data-expinfo]"); + +const resolveIdentity = ( + card: Element, + conversationId: string, +): { conversationId: string; messageId: string } | null => { + const wrapper = card.closest(".message-item-wrapper[data-expinfo]"); + const metadata = metadataElementFor(card); + if (!wrapper || !metadata) return null; + const cardInfo = infoFor(metadata); + const wrapperInfo = infoFor(wrapper); + if (!cardInfo || !wrapperInfo) return null; + const cardIdentity = identityFor(cardInfo); + const wrapperIdentity = identityFor(wrapperInfo); + if (!cardIdentity || !wrapperIdentity || cardIdentity.messageId !== wrapperIdentity.messageId) + return null; + if ( + (cardIdentity.conversationId !== null && cardIdentity.conversationId !== conversationId) || + (wrapperIdentity.conversationId !== null && + wrapperIdentity.conversationId !== conversationId) + ) + return null; + return { conversationId, messageId: cardIdentity.messageId }; +}; + +const snapshotFor = (pageWindow: OneTalkPageWindow, card: Element) => { + const root = card.querySelector(".tpl-wrapper") ?? card; + return parseOneTalkDomCard(pageWindow, root); +}; + +/** 从当前稳定会话中读取 DOM-only rendered-card observation,不推断任何业务类型。 */ +export const readOneTalkRenderedCardFromDom = ( + pageWindow: OneTalkPageWindow, + card: Element, +): OneTalkRenderedCardObservation | null => { + const before = readConversationSelection(pageWindow); + if (before.kind !== "single") return null; + const identity = resolveIdentity(card, before.conversationId); + const content = snapshotFor(pageWindow, card); + const after = readConversationSelection(pageWindow); + if ( + !identity || + !content || + after.kind !== "single" || + after.conversationId !== before.conversationId + ) + return null; + return { + ...identity, + content, + contentFingerprint: createOneTalkRenderedCardContentFingerprint(content), + observedAtMs: Date.now(), + }; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/dom.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/dom.ts new file mode 100644 index 0000000..1a0d20e --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/dom.ts @@ -0,0 +1,245 @@ +// 读取 DOM 卡片中经过白名单筛选的可见字段。 + +import { + isOneTalkRenderedCardImageUrl, + isOneTalkRenderedCardText, + type OneTalkRenderedCard, +} from "@trade-message-center/onetalk-contract"; +import type { OneTalkPageWindow } from "../../model.ts"; + +type FieldRow = { label: string; value: string }; + +type VisibilityElement = { + tagName?: unknown; + hidden?: unknown; + parentElement?: unknown; + getAttribute?: unknown; +}; + +export const MINIMUM_ORDER_LABELS = new Set(["minimum order", "最小订购量"]); + +const visibilityElementFor = (value: unknown): VisibilityElement | null => + typeof value === "object" && + value !== null && + ("tagName" in value || "hidden" in value || "getAttribute" in value) + ? (value as VisibilityElement) + : null; + +const attributeFor = (element: VisibilityElement, name: string): string | null => { + if (typeof element.getAttribute !== "function") return null; + const value = element.getAttribute(name); + return typeof value === "string" ? value : null; +}; + +const isVisibleNode = (pageWindow: OneTalkPageWindow, node: Node): boolean => { + const getComputedStyle = ( + pageWindow as OneTalkPageWindow & { + getComputedStyle?: ( + element: Element, + ) => Pick; + } + ).getComputedStyle; + let ancestor = visibilityElementFor(node) ?? node.parentElement; + while (ancestor) { + if ( + ancestor.tagName === "TEMPLATE" || + ancestor.hidden === true || + attributeFor(ancestor, "hidden") !== null || + attributeFor(ancestor, "aria-hidden") === "true" + ) + return false; + if (getComputedStyle && typeof ancestor.getAttribute === "function") { + const style = getComputedStyle(ancestor as Element); + if (style.display === "none" || style.visibility === "hidden") return false; + } + ancestor = ancestor.parentElement as VisibilityElement | null; + } + return true; +}; + +export const textFor = (pageWindow: OneTalkPageWindow, node: Node): string | null => { + if (!isVisibleNode(pageWindow, node)) return null; + const renderedText = (node as Node & { innerText?: unknown }).innerText; + const text = ( + typeof renderedText === "string" ? renderedText : (node.textContent ?? "") + ).trim(); + return isOneTalkRenderedCardText(text) ? text : null; +}; + +export const elementsUnder = (root: Element, className: string): Element[] => + Array.from(root.querySelectorAll?.(`.${className}`) ?? []); + +const hasClass = (node: Node, className: string): boolean => { + const element = node as Element & { classNames?: Set }; + return ( + element.classList?.contains(className) === true || + element.classNames?.has(className) === true + ); +}; + +const isTextViewNode = (node: Node): boolean => + hasClass(node, "TextView") || + attributeFor(node as VisibilityElement, "view-name") === "TextView"; + +export const labelKeyFor = (value: string): string => + value + .trim() + .replace(/[::]$/u, "") + .toLocaleLowerCase(); + +export const textViewElementsUnder = (pageWindow: OneTalkPageWindow, root: Element): Element[] => { + const elements: Element[] = []; + const visit = (node: Node): void => { + if (isTextViewNode(node)) { + if (textFor(pageWindow, node)) elements.push(node as Element); + return; + } + for (const child of Array.from(node.childNodes ?? [])) visit(child); + }; + visit(root); + return elements; +}; + +export const textViewsUnder = (pageWindow: OneTalkPageWindow, root: Element): string[] => + textViewElementsUnder(pageWindow, root).flatMap((element) => { + const text = textFor(pageWindow, element); + return text ? [text] : []; + }); + +export const hasExactlyOneVisibleText = ( + pageWindow: OneTalkPageWindow, + root: Element, + labels: ReadonlySet, +): boolean => + textViewsUnder(pageWindow, root).filter((value) => labels.has(labelKeyFor(value))).length === 1; + +export const imageUrlsUnder = (pageWindow: OneTalkPageWindow, node: Node): string[] | null => { + const candidate = node as Element; + const images = [ + ...(candidate.getAttribute?.("src") === null || + candidate.getAttribute?.("src") === undefined + ? [] + : [candidate]), + ...Array.from(candidate.querySelectorAll?.("img[src]") ?? []), + ]; + const unique = new Set(); + for (const image of images) { + if (!isVisibleNode(pageWindow, image)) return null; + const url = image.getAttribute("src"); + if (!isOneTalkRenderedCardImageUrl(url)) return null; + unique.add(url); + } + return unique.size > 0 ? [...unique] : null; +}; + +export const imageUrlUnder = (pageWindow: OneTalkPageWindow, node: Node): string | null => + imageUrlsUnder(pageWindow, node)?.[0] ?? null; + +const visibleTextChildren = (pageWindow: OneTalkPageWindow, node: Node): string[] | null => { + const values: string[] = []; + for (const child of Array.from(node.childNodes ?? [])) { + if (!isTextViewNode(child)) { + if ((child.textContent?.trim() ?? "") === "") continue; + return null; + } + const text = textFor(pageWindow, child); + if (text) values.push(text); + } + return values.length > 0 ? values : null; +}; + +export const fieldRowsFor = ( + pageWindow: OneTalkPageWindow, + root: Element, + excludedOwners: ReadonlySet = new Set(), +): FieldRow[] => { + const rows: FieldRow[] = []; + const visit = (node: Node): void => { + if (excludedOwners.has(node as Element)) return; + const children = Array.from(node.childNodes ?? []); + const values = visibleTextChildren(pageWindow, node); + if (values?.length === 2) { + rows.push({ label: values[0], value: values[1] }); + return; + } + for (const child of children) visit(child); + }; + visit(root); + return rows; +}; + +const minimumOrderFor = ( + pageWindow: OneTalkPageWindow, + node: Node, +): { value: string; unit: string } | null => { + const values = visibleTextChildren(pageWindow, node); + if (!values || values.length !== 3) return null; + if (!MINIMUM_ORDER_LABELS.has(labelKeyFor(values[0]))) return null; + return { value: values[1], unit: values[2] }; +}; + +const minimumOrderIn = ( + pageWindow: OneTalkPageWindow, + root: Node, +): { node: Node; value: string; unit: string } | null => { + const minimumOrder = minimumOrderFor(pageWindow, root); + if (minimumOrder) return { node: root, ...minimumOrder }; + for (const child of Array.from(root.childNodes ?? [])) { + const nested = minimumOrderIn(pageWindow, child); + if (nested) return nested; + } + return null; +}; + +const productOwnerFor = ( + pageWindow: OneTalkPageWindow, + root: Element, +): { + owner: Element; + minimumOrder: { value: string; unit: string }; +} | null => { + for (const owner of [...elementsUnder(root, "dx-event-node"), root]) { + const minimumOrder = minimumOrderIn(pageWindow, owner); + if (minimumOrder) + return { + owner, + minimumOrder: { value: minimumOrder.value, unit: minimumOrder.unit }, + }; + } + return null; +}; + +const priceIndexFor = (values: string[], before: number): number => { + for (let index = before - 1; index >= 0; index -= 1) { + if (/[$¥€£]\s*\d/u.test(values[index])) return index; + } + return -1; +}; + +export const productFieldsFor = ( + pageWindow: OneTalkPageWindow, + root: Element, +): Pick & { + owner: Element | null; +} => { + const product = productOwnerFor(pageWindow, root); + if (!product) return { owner: null }; + const values = textViewsUnder(pageWindow, product.owner); + const minimumOrderIndex = values.findIndex( + (value, index) => + MINIMUM_ORDER_LABELS.has(labelKeyFor(value)) && + values[index + 1] === product.minimumOrder.value && + values[index + 2] === product.minimumOrder.unit, + ); + if (minimumOrderIndex < 0) return { owner: null }; + const priceIndex = priceIndexFor(values, minimumOrderIndex); + const title = priceIndex > 0 ? values[priceIndex - 1] : undefined; + const image = imageUrlUnder(pageWindow, product.owner); + return { + owner: product.owner, + ...(title ? { title } : {}), + ...(priceIndex >= 0 ? { price: values[priceIndex] } : {}), + minimumOrder: `${product.minimumOrder.value} ${product.minimumOrder.unit}`, + ...(image ? { image } : {}), + }; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/index.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/index.ts new file mode 100644 index 0000000..2c30747 --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/index.ts @@ -0,0 +1,27 @@ +// 按唯一可见锚点分派订单、询盘和商品 DOM 解析器。 + +import type { OneTalkRenderedCard } from "@trade-message-center/onetalk-contract"; +import type { OneTalkPageWindow } from "../../model.ts"; +import { matchesOneTalkInquiryCard, parseOneTalkInquiryCard } from "./inquiry.ts"; +import { matchesOneTalkOrderCard, parseOneTalkOrderCard } from "./order.ts"; +import { matchesOneTalkProductCard, parseOneTalkProductCard } from "./product.ts"; + +type OneTalkDomCardParser = { + matches: (pageWindow: OneTalkPageWindow, root: Element) => boolean; + parse: (pageWindow: OneTalkPageWindow, root: Element) => OneTalkRenderedCard | null; +}; + +const CARD_PARSERS: readonly OneTalkDomCardParser[] = [ + { matches: matchesOneTalkOrderCard, parse: parseOneTalkOrderCard }, + { matches: matchesOneTalkInquiryCard, parse: parseOneTalkInquiryCard }, + { matches: matchesOneTalkProductCard, parse: parseOneTalkProductCard }, +]; + +/** 仅允许一个可见类别锚点决定本次 DOM 字段投影。 */ +export const parseOneTalkDomCard = ( + pageWindow: OneTalkPageWindow, + root: Element, +): OneTalkRenderedCard | null => { + const matches = CARD_PARSERS.filter((parser) => parser.matches(pageWindow, root)); + return matches.length === 1 ? matches[0].parse(pageWindow, root) : null; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/inquiry.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/inquiry.ts new file mode 100644 index 0000000..10df371 --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/inquiry.ts @@ -0,0 +1,65 @@ +// 解析由详细需求锚点唯一标识的可见询盘字段。 + +import { + isOneTalkRenderedCard, + type OneTalkRenderedCard, +} from "@trade-message-center/onetalk-contract"; +import type { OneTalkPageWindow } from "../../model.ts"; +import { + elementsUnder, + fieldRowsFor, + hasExactlyOneVisibleText, + imageUrlUnder, + labelKeyFor, + textFor, + textViewsUnder, +} from "./dom.ts"; + +const INQUIRY_ANCHOR_LABELS = new Set(["详细需求", "detailed requirements"]); +const PURCHASE_QUANTITY_LABELS = new Set(["采购数量", "purchase quantity"]); + +const inquiryProductFor = ( + pageWindow: OneTalkPageWindow, + root: Element, +): Pick => { + const owners = elementsUnder(root, "list-item"); + const title = owners + .flatMap((owner) => textViewsUnder(pageWindow, owner)) + .find((value): value is string => value.length > 0); + const image = owners + .map((owner) => imageUrlUnder(pageWindow, owner)) + .find((value): value is string => value !== null); + return { + ...(title ? { title } : {}), + ...(image ? { image } : {}), + }; +}; + +export const matchesOneTalkInquiryCard = (pageWindow: OneTalkPageWindow, root: Element): boolean => + hasExactlyOneVisibleText(pageWindow, root, INQUIRY_ANCHOR_LABELS); + +/** 从已确认询盘结构中投影需求、商品和标注属性。 */ +export const parseOneTalkInquiryCard = ( + pageWindow: OneTalkPageWindow, + root: Element, +): OneTalkRenderedCard | null => { + const descriptions = elementsUnder(root, "description-container"); + const requirementText = descriptions + .map((element) => textFor(pageWindow, element)) + .find((value): value is string => value !== null); + const productOwners = elementsUnder(root, "list-item"); + const excludedOwners = new Set([...descriptions, ...productOwners]); + const rows = fieldRowsFor(pageWindow, root, excludedOwners); + const purchaseQuantity = rows + .filter((row) => PURCHASE_QUANTITY_LABELS.has(labelKeyFor(row.label))) + .map((row) => row.value) + .find((value): value is string => value.length > 0); + const product = inquiryProductFor(pageWindow, root); + const snapshot: OneTalkRenderedCard = { + version: 1, + ...product, + ...(requirementText ? { requirementText } : {}), + ...(purchaseQuantity ? { purchaseQuantity } : {}), + }; + return isOneTalkRenderedCard(snapshot) ? snapshot : null; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/order.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/order.ts new file mode 100644 index 0000000..54ad9b7 --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/order.ts @@ -0,0 +1,116 @@ +// 解析由订单状态锚点唯一标识的可见订单字段。 + +import { + isOneTalkRenderedCard, + type OneTalkRenderedCard, +} from "@trade-message-center/onetalk-contract"; +import type { OneTalkPageWindow } from "../../model.ts"; +import { + elementsUnder, + fieldRowsFor, + imageUrlUnder, + labelKeyFor, + hasExactlyOneVisibleText, + textFor, + textViewElementsUnder, +} from "./dom.ts"; + +const ORDER_ANCHOR_LABELS = new Set(["订单状态", "order status"]); +const DIRECT_PRODUCT_COUNT = /^\d+\s+products?$/iu; +const ORDER_FIELD_LABELS = new Map([ + ["product count", "productCount"], + ["商品数量", "productCount"], + ["status", "status"], + ["订单状态", "status"], + ["total", "total"], + ["合计", "total"], + ["delivery date", "deliveryDate"], + ["交货日期", "deliveryDate"], + ["shipping address", "shippingAddress"], + ["收件地址", "shippingAddress"], + ["收货地址", "shippingAddress"], +]); + +const productCountFor = (display: string): { display: string; value: number | null } => { + const match = display.match(/\d+/u)?.[0]; + const value = match ? Number(match) : null; + return { + display, + value: + typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null, + }; +}; + +export const matchesOneTalkOrderCard = (pageWindow: OneTalkPageWindow, root: Element): boolean => + hasExactlyOneVisibleText(pageWindow, root, ORDER_ANCHOR_LABELS); + +const directProductCountElementFor = ( + pageWindow: OneTalkPageWindow, + root: Element, +): Element | null => + textViewElementsUnder(pageWindow, root).find((element) => { + const text = textFor(pageWindow, element); + return text !== null && DIRECT_PRODUCT_COUNT.test(text); + }) ?? null; + +/** + * 订单商品图与数量展示是同一局部容器内的前置兄弟;不从整张卡取首图,避开顶部箭头图标。 + */ +const precedingImageFor = ( + pageWindow: OneTalkPageWindow, + root: Element, + start: Element, +): string | null => { + let child: Element = start; + let parent = child.parentElement; + while (parent && parent !== root) { + const siblings = Array.from(parent.childNodes ?? []); + const index = siblings.indexOf(child); + for (let siblingIndex = index - 1; siblingIndex >= 0; siblingIndex -= 1) { + const image = imageUrlUnder(pageWindow, siblings[siblingIndex]); + if (image) return image; + } + child = parent; + parent = parent.parentElement; + } + return null; +}; + +/** 从已确认订单结构中投影字段,未识别行不跨越卡片边界。 */ +export const parseOneTalkOrderCard = ( + pageWindow: OneTalkPageWindow, + root: Element, +): OneTalkRenderedCard | null => { + const named: Pick< + OneTalkRenderedCard, + "productCount" | "status" | "total" | "deliveryDate" | "shippingAddress" + > = {}; + for (const row of fieldRowsFor(pageWindow, root)) { + const field = ORDER_FIELD_LABELS.get(labelKeyFor(row.label)); + if (field === "productCount" && !named.productCount) + named.productCount = productCountFor(row.value); + else if (field === "status" && !named.status) named.status = row.value; + else if (field === "total" && !named.total) named.total = row.value; + else if (field === "deliveryDate" && !named.deliveryDate) named.deliveryDate = row.value; + else if (field === "shippingAddress" && !named.shippingAddress) + named.shippingAddress = row.value; + } + const directProductCountElement = directProductCountElementFor(pageWindow, root); + if (!named.productCount && directProductCountElement) { + const display = textFor(pageWindow, directProductCountElement); + if (display) named.productCount = productCountFor(display); + } + const image = + (directProductCountElement + ? precedingImageFor(pageWindow, root, directProductCountElement) + : null) ?? + elementsUnder(root, "list-item") + .map((owner) => imageUrlUnder(pageWindow, owner)) + .find((value): value is string => value !== null); + const snapshot: OneTalkRenderedCard = { + version: 1, + ...named, + ...(image ? { image } : {}), + }; + return isOneTalkRenderedCard(snapshot) ? snapshot : null; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/product.ts b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/product.ts new file mode 100644 index 0000000..d689bfe --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader/product.ts @@ -0,0 +1,27 @@ +// 解析由最小订购量锚点唯一标识的可见商品字段。 + +import { + isOneTalkRenderedCard, + type OneTalkRenderedCard, +} from "@trade-message-center/onetalk-contract"; +import type { OneTalkPageWindow } from "../../model.ts"; +import { hasExactlyOneVisibleText, MINIMUM_ORDER_LABELS, productFieldsFor } from "./dom.ts"; + +export const matchesOneTalkProductCard = (pageWindow: OneTalkPageWindow, root: Element): boolean => + hasExactlyOneVisibleText(pageWindow, root, MINIMUM_ORDER_LABELS); + +/** 从已确认商品结构中投影商品、价格与最小订购量。 */ +export const parseOneTalkProductCard = ( + pageWindow: OneTalkPageWindow, + root: Element, +): OneTalkRenderedCard | null => { + const product = productFieldsFor(pageWindow, root); + const snapshot: OneTalkRenderedCard = { + version: 1, + ...(product.title ? { title: product.title } : {}), + ...(product.price ? { price: product.price } : {}), + ...(product.minimumOrder ? { minimumOrder: product.minimumOrder } : {}), + ...(product.image ? { image: product.image } : {}), + }; + return isOneTalkRenderedCard(snapshot) ? snapshot : null; +}; 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 dbbe5b6..045bdfd 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,124 +1,74 @@ -// 安装渲染卡片 observer:DOM mutation 即时读取,另以当前列表周期重扫补足 Fiber 延迟就绪。 +// 安装 DOM-only rendered-card observer 并在页面关闭时释放资源。 import type { OneTalkRenderedCardObservation } from "@trade-message-center/onetalk-contract"; import type { OneTalkPageWindow } from "../model.ts"; -import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts"; -import { readOneTalkRenderedCard } from "./react-card-reader.ts"; +import { readOneTalkRenderedCardFromDom } from "./dom-card-reader.ts"; -const RENDERED_CARD_RESCAN_INTERVAL_MS = 1_000; +const CARD_SELECTOR = ".session-rich-content.card"; -const messageListRootFor = ( - document: NonNullable, -): Element | null => { - const firstMessage = document.querySelectorAll(".message-item-wrapper")[0]; - if (!firstMessage) return null; - let parent = firstMessage.parentElement; - while (parent) { - if (parent.querySelectorAll(".message-item-wrapper").length > 1) return parent; - parent = parent.parentElement; - } - return firstMessage.parentElement; +const isCardElement = (value: unknown): value is Element => + typeof value === "object" && + value !== null && + "matches" in value && + typeof (value as { matches?: unknown }).matches === "function"; + +const cardsFor = (node: unknown): Element[] => { + const element = isCardElement(node) + ? node + : typeof node === "object" && node !== null && "parentElement" in node + ? (node as { parentElement?: unknown }).parentElement + : null; + if (!isCardElement(element)) return []; + const cards = new Set(); + if (element.matches(CARD_SELECTOR)) cards.add(element); + const closest = element.closest?.(CARD_SELECTOR); + if (isCardElement(closest) && closest.matches(CARD_SELECTOR)) cards.add(closest); + for (const card of Array.from(element.querySelectorAll?.(CARD_SELECTOR) ?? [])) cards.add(card); + return [...cards]; }; -const renderedCardWrapperFor = (node: Node): Element | null => { - const element = node instanceof Element ? node : node.parentElement; - return element?.matches(".message-item-wrapper") - ? element - : (element?.closest(".message-item-wrapper") ?? null); -}; - -/** 安装 Fiber card reader;未验证类别一律不上传。 */ +/** 安装当前页面的严格 DOM 卡片采集;未知或不完整卡不发送。 */ export const installOneTalkRenderedCardObserver = ( pageWindow: OneTalkPageWindow, - sink: ( - observations: OneTalkRenderedCardObservation[], - baseEvidence: OneTalkPageRenderedCardBaseEvidence[], - ) => void, + sink: (observations: OneTalkRenderedCardObservation[]) => void, ): void => { const document = pageWindow.document; - if (!document || !pageWindow.MutationObserver) return; + const documentElement = (document as Document | undefined)?.documentElement; + if (!document || !documentElement || !pageWindow.MutationObserver) return; const observedCardKeys = new Set(); let disposed = false; - const inspect = (wrapper: Element): void => { + const publish = (card: Element): void => { if (disposed) return; - const read = readOneTalkRenderedCard(pageWindow, wrapper); - if (!read) return; - const observationKey = JSON.stringify([ - read.observation.conversationId, - read.observation.messageId, - read.observation.contentFingerprint, + const observation = readOneTalkRenderedCardFromDom(pageWindow, card); + if (!observation) return; + const key = JSON.stringify([ + observation.conversationId, + observation.messageId, + observation.contentFingerprint, ]); - if (observedCardKeys.has(observationKey)) return; - observedCardKeys.add(observationKey); - sink([read.observation], [read.baseEvidence]); + if (observedCardKeys.has(key)) return; + observedCardKeys.add(key); + sink([observation]); }; - let root: Element | null = null; - let observingDocument = false; const observer = new pageWindow.MutationObserver((records) => { if (disposed) return; - if (messageListRootFor(document) !== root) { - bind(); - return; - } for (const record of records) { - if (root && record.target !== root && !root.contains(record.target)) continue; - const wrappers = new Set(); - const targetWrapper = renderedCardWrapperFor(record.target); - if (targetWrapper) wrappers.add(targetWrapper); + for (const card of cardsFor(record.target)) publish(card); for (const node of Array.from(record.addedNodes)) { - if (!(node instanceof Element)) continue; - const wrapper = renderedCardWrapperFor(node); - if (wrapper) wrappers.add(wrapper); - for (const wrapper of Array.from(node.querySelectorAll(".message-item-wrapper"))) - wrappers.add(wrapper); + for (const card of cardsFor(node)) publish(card); } - for (const wrapper of wrappers) inspect(wrapper); } }); - const bind = (): void => { - if (disposed) return; - const nextRoot = messageListRootFor(document); - if (nextRoot === root && (root !== null || observingDocument)) return; - observer.disconnect(); - root = nextRoot; - if (!root) { - observingDocument = true; - observer.observe(document as unknown as Node, { childList: true, subtree: true }); - return; - } - observingDocument = false; - for (const wrapper of Array.from(root.querySelectorAll(".message-item-wrapper"))) - inspect(wrapper); - observer.observe(root, { - attributes: true, - characterData: true, - childList: true, - subtree: true, - }); - if (root.parentElement) observer.observe(root.parentElement, { childList: true }); - }; - - const rescanCurrentMessageList = (): void => { - if (disposed) return; - if (messageListRootFor(document) !== root) { - bind(); - return; - } - if (!root) return; - for (const wrapper of Array.from(root.querySelectorAll(".message-item-wrapper"))) - inspect(wrapper); - }; - - bind(); - const rescanTimer = globalThis.setInterval( - rescanCurrentMessageList, - RENDERED_CARD_RESCAN_INTERVAL_MS, - ); - pageWindow.addEventListener("popstate", bind); - pageWindow.addEventListener("hashchange", bind); + for (const card of Array.from(document.querySelectorAll(CARD_SELECTOR))) publish(card); + observer.observe(documentElement, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ["data-expinfo", "src", "style"], + }); pageWindow.addEventListener("pagehide", () => { disposed = true; - globalThis.clearInterval(rescanTimer); observer.disconnect(); }); }; 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 deleted file mode 100644 index 075a059..0000000 --- a/apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts +++ /dev/null @@ -1,337 +0,0 @@ -// 从 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/page-bridge/main.ts b/apps/chrome-extension/src/onetalk/page-bridge/main.ts index 196c6d2..dcc5064 100644 --- a/apps/chrome-extension/src/onetalk/page-bridge/main.ts +++ b/apps/chrome-extension/src/onetalk/page-bridge/main.ts @@ -36,7 +36,6 @@ import { createOneTalkPageRenderedCardObservedMessage, ONE_TALK_PAGE_BRIDGE_RECONNECT_SIGNAL, type PageCommandResult, - type OneTalkPageRenderedCardBaseEvidence, } from "./model.ts"; export type OneTalkPageCommandHandler = ( @@ -224,23 +223,16 @@ export const createOneTalkPageObservedSink = ( /** 创建只发送经 MAIN 白名单投影的渲染卡片补全 sink。 */ export const createOneTalkPageRenderedCardObservedSink = ( pageWindow: OneTalkMainPageBridgeWindow, -): (( - observations: OneTalkRenderedCardObservation[], - baseEvidence: OneTalkPageRenderedCardBaseEvidence[], -) => void) => { +): ((observations: OneTalkRenderedCardObservation[]) => void) => { const origin = pageOrigin(pageWindow); - return (observations, baseEvidence: OneTalkPageRenderedCardBaseEvidence[]) => { + return (observations) => { if (!origin || observations.length === 0) return; const channelAccountId = readChannelAccountId(pageWindow); if (!channelAccountId) return; postPageMessage( pageWindow, origin, - createOneTalkPageRenderedCardObservedMessage( - channelAccountId, - observations, - baseEvidence, - ), + createOneTalkPageRenderedCardObservedMessage(channelAccountId, observations), ); }; }; diff --git a/apps/chrome-extension/src/onetalk/page-bridge/model.ts b/apps/chrome-extension/src/onetalk/page-bridge/model.ts index 6975447..d5c44be 100644 --- a/apps/chrome-extension/src/onetalk/page-bridge/model.ts +++ b/apps/chrome-extension/src/onetalk/page-bridge/model.ts @@ -7,7 +7,7 @@ import { isOneTalkContactProfile, isOneTalkMessage, isOneTalkBuyerFact, - isOneTalkRenderedCardContent, + isOneTalkRenderedCard, type OneTalkBuyerFact, type OneTalkContactProfile, type OneTalkRenderedCardObservation, @@ -82,22 +82,13 @@ export type OneTalkPageBuyerFactsObservedMessage = { facts: OneTalkBuyerFact[]; }; -/** MAIN 已完成白名单投影的渲染卡片补全;不携带 DOM/Fiber/raw props。 */ +/** MAIN 已完成白名单投影的渲染卡片补全;不携带原始页面对象。 */ export type OneTalkPageRenderedCardObservedMessage = { source: typeof ONE_TALK_PAGE_BRIDGE_SOURCE; version: typeof ONE_TALK_PAGE_BRIDGE_VERSION; type: "onetalk.page.rendered-card-observed"; channelAccountId: string; observations: OneTalkRenderedCardObservation[]; - /** MAIN-only proof used to compare against the durable base candidate; never enters wire. */ - baseEvidence: OneTalkPageRenderedCardBaseEvidence[]; -}; - -export type OneTalkPageRenderedCardBaseEvidence = { - conversationId: string; - messageId: string; - direction: "received" | "sent"; - sentAtMs: number; }; export type OneTalkPageHistoryProgress = { @@ -531,14 +522,7 @@ const decodeRenderedCardObservedMessage = ( value: Record, ): OneTalkPageRenderedCardObservedMessage | null => { if ( - !hasExactKeys(value, [ - "source", - "version", - "type", - "channelAccountId", - "observations", - "baseEvidence", - ]) || + !hasExactKeys(value, ["source", "version", "type", "channelAccountId", "observations"]) || typeof value.channelAccountId !== "string" || value.channelAccountId.trim().length === 0 || !Array.isArray(value.observations) || @@ -561,32 +545,13 @@ const decodeRenderedCardObservedMessage = ( typeof observation.observedAtMs === "number" && Number.isSafeInteger(observation.observedAtMs) && observation.observedAtMs >= 0 && - isOneTalkRenderedCardContent(observation.content) && + isOneTalkRenderedCard(observation.content) && observation.contentFingerprint === createOneTalkRenderedCardContentFingerprint(observation.content), ) ) return null; const observations = value.observations as Record[]; - const baseEvidence = value.baseEvidence; - if ( - !Array.isArray(baseEvidence) || - baseEvidence.length !== observations.length || - !baseEvidence.every( - (evidence, index) => - isPlainRecord(evidence) && - hasExactKeys(evidence, ["conversationId", "messageId", "direction", "sentAtMs"]) && - typeof evidence.conversationId === "string" && - typeof evidence.messageId === "string" && - (evidence.direction === "received" || evidence.direction === "sent") && - typeof evidence.sentAtMs === "number" && - Number.isSafeInteger(evidence.sentAtMs) && - evidence.sentAtMs >= 0 && - evidence.conversationId === observations[index]?.conversationId && - evidence.messageId === observations[index]?.messageId, - ) - ) - return null; return { source: ONE_TALK_PAGE_BRIDGE_SOURCE, version: ONE_TALK_PAGE_BRIDGE_VERSION, @@ -599,12 +564,6 @@ const decodeRenderedCardObservedMessage = ( contentFingerprint: observation.contentFingerprint as string, observedAtMs: observation.observedAtMs as number, })), - baseEvidence: baseEvidence.map((evidence) => ({ - conversationId: evidence.conversationId as string, - messageId: evidence.messageId as string, - direction: evidence.direction as "received" | "sent", - sentAtMs: evidence.sentAtMs as number, - })), }; }; @@ -792,7 +751,6 @@ export const createOneTalkPageBuyerFactsObservedMessage = ( export const createOneTalkPageRenderedCardObservedMessage = ( channelAccountId: string, observations: OneTalkRenderedCardObservation[], - baseEvidence: OneTalkPageRenderedCardBaseEvidence[], ): OneTalkPageRenderedCardObservedMessage => ({ source: ONE_TALK_PAGE_BRIDGE_SOURCE, version: ONE_TALK_PAGE_BRIDGE_VERSION, @@ -802,7 +760,6 @@ export const createOneTalkPageRenderedCardObservedMessage = ( ...observation, content: JSON.parse(JSON.stringify(observation.content)), })), - baseEvidence: baseEvidence.map((evidence) => ({ ...evidence })), }); /** 创建发送到页面的命令消息。 */ diff --git a/apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts b/apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts index 0ffb81d..b796c68 100644 --- a/apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts +++ b/apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts @@ -321,7 +321,7 @@ export class OneTalkPageRuntimeHost { const coordinator = options.getActiveRenderedCardCoordinator?.(); if (!coordinator || channelAccountId !== this.lastPageIdentity?.channelAccountId) return; - await coordinator.observe(message.observations, message.baseEvidence); + await coordinator.observe(message.observations); void sender; }, onPageMessage: () => undefined, diff --git a/apps/chrome-extension/src/onetalk/service-worker/rendered-card-coordinator.ts b/apps/chrome-extension/src/onetalk/service-worker/rendered-card-coordinator.ts index 2eede5e..eda67cc 100644 --- a/apps/chrome-extension/src/onetalk/service-worker/rendered-card-coordinator.ts +++ b/apps/chrome-extension/src/onetalk/service-worker/rendered-card-coordinator.ts @@ -8,13 +8,9 @@ import { } from "@trade-message-center/onetalk-contract"; import type { OneTalkBrightClient, OneTalkBrightClientState } from "./transport/bright-client.ts"; import type { OneTalkRenderedCardLedgerStore } from "./storage.ts"; -import type { OneTalkPageRenderedCardBaseEvidence } from "../page-bridge/model.ts"; export type OneTalkRenderedCardCoordinator = { - observe: ( - observations: OneTalkRenderedCardObservation[], - baseEvidence?: OneTalkPageRenderedCardBaseEvidence[], - ) => Promise; + observe: (observations: OneTalkRenderedCardObservation[]) => Promise; handleFrame: (frame: OneTalkFrame) => void; handleStatus: (state: OneTalkBrightClientState) => void; handlePageReady: () => Promise; @@ -89,29 +85,12 @@ export const createOneTalkRenderedCardCoordinator = (options: { report(error); } }; - const observe = async ( - observations: OneTalkRenderedCardObservation[], - baseEvidence: OneTalkPageRenderedCardBaseEvidence[] = [], - ): Promise => { + const observe = async (observations: OneTalkRenderedCardObservation[]): Promise => { if (disposed) return; - if ( - baseEvidence.length !== observations.length || - !observations.every( - (observation, index) => - baseEvidence[index]?.conversationId === observation.conversationId && - baseEvidence[index]?.messageId === observation.messageId, - ) - ) - return; - for (const [index, observation] of observations.entries()) { - const evidence = baseEvidence[index]!; + for (const observation of observations) { await options.ledger.observe({ channelAccountId: options.scope.channelAccountId, observation, - baseEvidence: { - direction: evidence.direction, - sentAtMs: evidence.sentAtMs, - }, }); if (disposed) return; } diff --git a/apps/chrome-extension/src/onetalk/service-worker/storage.ts b/apps/chrome-extension/src/onetalk/service-worker/storage.ts index 0317c41..31dde87 100644 --- a/apps/chrome-extension/src/onetalk/service-worker/storage.ts +++ b/apps/chrome-extension/src/onetalk/service-worker/storage.ts @@ -1,7 +1,7 @@ // 持久化 OneTalk 同步账本与页面观察 import { - cloneOneTalkRenderedCardContent, + cloneOneTalkRenderedCard, isOneTalkMessage, type OneTalkContactProfile, type OneTalkObservedMessage, @@ -9,7 +9,7 @@ import { type OneTalkSyncAnomalyCode, type OneTalkSyncMode, type OneTalkSyncResult, - type OneTalkRenderedCardContent, + type OneTalkRenderedCard, type OneTalkRenderedCardObservation, } from "@trade-message-center/onetalk-contract"; @@ -141,13 +141,9 @@ export type OneTalkRenderedCardLedgerRecord = { channelAccountId: string; conversationId: string; messageId: string; - content: OneTalkRenderedCardContent; + content: OneTalkRenderedCard; contentFingerprint: string; observedAtMs: number; - /** MAIN Fiber identity evidence retained with the independently uploaded card fact. */ - baseDirection: "received" | "sent"; - /** MAIN Fiber sendTime evidence retained with the independently uploaded card fact. */ - baseSentAtMs: number; status: "pending_ack" | "confirmed" | "rejected"; requestId?: string; firstObservedAt: number; @@ -162,10 +158,6 @@ export type OneTalkRenderedCardLedgerStore = { observe: (input: { channelAccountId: string; observation: OneTalkRenderedCardObservation; - baseEvidence: { - direction: "received" | "sent"; - sentAtMs: number; - }; observedAt?: number; }) => Promise; get: ( @@ -536,7 +528,7 @@ export const createOneTalkRenderedCardLedgerStore = ( const completion = transactionResult(transaction); transaction.objectStore(ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME).put({ ...value, - content: cloneOneTalkRenderedCardContent(value.content), + content: cloneOneTalkRenderedCard(value.content), }); await completion; }; @@ -556,7 +548,6 @@ export const createOneTalkRenderedCardLedgerStore = ( const observeOne = async (input: { channelAccountId: string; observation: OneTalkRenderedCardObservation; - baseEvidence: { direction: "received" | "sent"; sentAtMs: number }; observedAt?: number; }): Promise => { const current = await get( @@ -589,11 +580,9 @@ export const createOneTalkRenderedCardLedgerStore = ( channelAccountId: input.channelAccountId, conversationId: input.observation.conversationId, messageId: input.observation.messageId, - content: cloneOneTalkRenderedCardContent(input.observation.content), + content: cloneOneTalkRenderedCard(input.observation.content), contentFingerprint: input.observation.contentFingerprint, observedAtMs: input.observation.observedAtMs, - baseDirection: input.baseEvidence.direction, - baseSentAtMs: input.baseEvidence.sentAtMs, status: "pending_ack", firstObservedAt: observedAt, updatedAt: observedAt, @@ -604,7 +593,6 @@ export const createOneTalkRenderedCardLedgerStore = ( const observe = (input: { channelAccountId: string; observation: OneTalkRenderedCardObservation; - baseEvidence: { direction: "received" | "sent"; sentAtMs: number }; observedAt?: number; }): Promise => { const key = candidateKey( diff --git a/apps/chrome-extension/test/onetalk-page-bridge.test.js b/apps/chrome-extension/test/onetalk-page-bridge.test.js index 58844a5..5760ca5 100644 --- a/apps/chrome-extension/test/onetalk-page-bridge.test.js +++ b/apps/chrome-extension/test/onetalk-page-bridge.test.js @@ -310,8 +310,25 @@ test("decodes one versioned JSON envelope and rejects malformed shapes", () => { ); }); -test("rejects rendered-card observations with forged identity or fingerprint fields", () => { +test("accepts only generic rendered-card observations and rejects forged fields", () => { const content = { + version: 1, + title: "Order", + image: "https://img.alicdn.com/order.jpg", + productCount: { display: "5 products", value: 5 }, + status: "Paid", + }; + const observation = { + conversationId: "conversation-1", + messageId: "message-1", + content, + contentFingerprint: createOneTalkRenderedCardContentFingerprint(content), + observedAtMs: 1_700_000_000_000, + }; + const message = createOneTalkPageRenderedCardObservedMessage("account-1", [observation]); + assert.deepEqual(decodeOneTalkPageMessage(message), message); + + const legacyContent = { version: 1, kind: "rendered_order", title: "Order", @@ -322,26 +339,12 @@ test("rejects rendered-card observations with forged identity or fingerprint fie delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null }, action: { label: null, status: null }, }; - const observation = { - conversationId: "conversation-1", - messageId: "message-1", - content, - contentFingerprint: createOneTalkRenderedCardContentFingerprint(content), - observedAtMs: 1_700_000_000_000, + const legacyObservation = { + ...observation, + content: legacyContent, + contentFingerprint: createOneTalkRenderedCardContentFingerprint(legacyContent), }; - const message = createOneTalkPageRenderedCardObservedMessage( - "account-1", - [observation], - [ - { - conversationId: observation.conversationId, - messageId: observation.messageId, - direction: "received", - sentAtMs: 1_699_999_999_999, - }, - ], - ); - assert.deepEqual(decodeOneTalkPageMessage(message), message); + assert.equal(decodeOneTalkPageMessage({ ...message, observations: [legacyObservation] }), null); assert.equal( decodeOneTalkPageMessage({ ...message, @@ -366,7 +369,7 @@ 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", () => { +test("rejects legacy typed rendered products at the observed-frame boundary", () => { const content = { version: 1, kind: "rendered_product", @@ -393,47 +396,9 @@ test("decodes canonical rendered products and rejects forged product references" 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, - ); - } + assert.equal(decodeOneTalkPageMessage(message), null); }); test("deep-clones extended buyer facts at the page bridge boundary", () => { diff --git a/apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js b/apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js index 0e2c8c2..f5f6d1c 100644 --- a/apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js +++ b/apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js @@ -1,4 +1,4 @@ -// 验证渲染卡片 durable-first、基础确认门和精确 ACK +// 验证 type-agnostic 渲染卡片 durable-first 与精确 ACK。 import assert from "node:assert/strict"; import test from "node:test"; @@ -9,14 +9,9 @@ import { createOneTalkRenderedCardCoordinator } from "../src/onetalk/service-wor const scope = { channelAccountId: "account-1", deviceId: "device-1" }; 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 }, + status: "Paid", + total: "US $10.00", + image: "https://img.alicdn.com/card.jpg", }; const observation = { conversationId: "conversation-1", @@ -30,28 +25,17 @@ const key = JSON.stringify([ observation.conversationId, observation.messageId, ]); -const baseEvidence = [ - { - conversationId: observation.conversationId, - messageId: observation.messageId, - direction: "received", - sentAtMs: 99, - }, -]; - test("writes the card ledger before immediate send, resends after reconnect, and exact-matches ACK", async () => { const timeline = []; const records = new Map(); const acknowledgements = []; const ledger = { - observe: async ({ channelAccountId, observation: value, baseEvidence: evidence }) => { + observe: async ({ channelAccountId, observation: value }) => { timeline.push("write"); const record = { key, channelAccountId, ...value, - baseDirection: evidence.direction, - baseSentAtMs: evidence.sentAtMs, status: "pending_ack", firstObservedAt: 1, updatedAt: 1, @@ -105,7 +89,7 @@ test("writes the card ledger before immediate send, resends after reconnect, and createRequestId: () => "card-request", }); - await coordinator.observe([observation], baseEvidence); + await coordinator.observe([observation]); assert.equal(frames.length, 1); assert.equal(frames[0].type, "rendered.card.observed"); assert.deepEqual(timeline, ["write", "send"]); @@ -152,8 +136,6 @@ test("recovers a pending record after worker restart without a base candidate", key, channelAccountId: scope.channelAccountId, ...observation, - baseDirection: "received", - baseSentAtMs: 99, status: "pending_ack", firstObservedAt: 1, updatedAt: 1, diff --git a/apps/chrome-extension/test/onetalk-rendered-card-observer.test.js b/apps/chrome-extension/test/onetalk-rendered-card-observer.test.js index bf5c574..294dc92 100644 --- a/apps/chrome-extension/test/onetalk-rendered-card-observer.test.js +++ b/apps/chrome-extension/test/onetalk-rendered-card-observer.test.js @@ -1,504 +1,191 @@ -// 验证渲染卡片 observer 在消息列表异步挂载时仍能建立监听。 +// 验证 DOM card observer 只扫描渲染卡片并在页面关闭时释放监听。 import assert from "node:assert/strict"; import test from "node:test"; import { installOneTalkRenderedCardObserver } from "../src/onetalk/main-page/card-observer/entry.ts"; -test("installs a mutation observer before the first message wrapper exists", () => { +const cardFor = () => { + const wrapper = { + getAttribute: () => + JSON.stringify({ messageId: "message-1", conversationCode: "conversation-1" }), + }; + return { + textNodes: [{ nodeValue: "Order" }, { nodeValue: "Widget" }], + getAttribute: () => + JSON.stringify({ + messageId: "message-1", + conversationCode: "conversation-1", + cardType: 54, + messageType: 1, + }), + closest: () => wrapper, + matches: (selector) => + selector === ".session-rich-content.card" || selector === "[data-expinfo]", + querySelector: (selector) => { + if (selector === "[data-expinfo]") return card; + return null; + }, + querySelectorAll: (selector) => + selector === "img[src]" + ? [ + { + getAttribute: (name) => + name === "src" ? "https://img.alicdn.com/card.jpg" : null, + }, + ] + : [], + }; +}; + +const card = cardFor(); + +const fixtureNode = ({ text = null, imageUrl = null, classNames = [] } = {}) => { + const value = { + childNodes: [], + textContent: text, + imageUrl, + classNames: new Set(classNames), + parentElement: null, + getAttribute(name) { + return name === "src" ? this.imageUrl : null; + }, + querySelectorAll: (selector) => { + if (selector.startsWith(".")) return []; + assert.equal(selector, "img[src]"); + return []; + }, + }; + return value; +}; + +const validCardForMutation = () => { + const root = fixtureNode(); + const labelNode = fixtureNode({ text: "订单状态", classNames: ["TextView"] }); + const statusNode = fixtureNode({ text: "Pending", classNames: ["TextView"] }); + const row = fixtureNode(); + row.childNodes.push(labelNode, statusNode); + labelNode.parentElement = row; + statusNode.parentElement = row; + root.childNodes.push(row); + row.parentElement = root; + const wrapper = { + getAttribute: () => + JSON.stringify({ messageId: "message-1", conversationCode: "conversation-1" }), + }; + const card = { + closest: () => wrapper, + matches: (selector) => + selector === ".session-rich-content.card" || selector === "[data-expinfo]", + getAttribute: (name) => + name === "data-expinfo" + ? JSON.stringify({ messageId: "message-1", conversationCode: "conversation-1" }) + : null, + querySelector: (selector) => { + if (selector === "[data-expinfo]") return card; + if (selector === ".tpl-wrapper") return root; + return null; + }, + }; + root.parentElement = card; + statusNode.parentElement = card; + return { card, statusNode }; +}; + +test("rejects unrecognized cards while observing mounts without timers or wrapper scans", () => { const observers = []; const listeners = new Map(); + const documentElement = {}; const page = { location: { href: "https://onetalk.alibaba.com/message/default.htm" }, document: { + documentElement, querySelectorAll: (selector) => { - assert.equal(selector, ".message-item-wrapper"); - return []; + if (selector === ".contact-item-container.selected[data-cid]") + return [{ getAttribute: () => "conversation-1" }]; + if (selector === ".session-rich-content.card") return [card]; + assert.fail(`unexpected selector: ${selector}`); + }, + createTreeWalker: (target) => { + let index = 0; + return { nextNode: () => target.textNodes[index++] ?? null }; }, }, MutationObserver: class { constructor(callback) { this.callback = callback; - this.targets = []; + this.options = null; observers.push(this); } - - observe(target) { - this.targets.push(target); + observe(target, options) { + assert.equal(target, documentElement); + this.options = options; + } + disconnect() { + this.disconnected = true; } - - disconnect() {} }, addEventListener: (type, listener) => listeners.set(type, listener), }; + const observed = []; + installOneTalkRenderedCardObserver(page, (observations) => observed.push(...observations)); - installOneTalkRenderedCardObserver(page, () => undefined); - - assert.equal(observers.length, 1); - assert.equal(observers[0].targets.length, 1); - assert.equal(listeners.has("pagehide"), true); + assert.equal(observed.length, 0); + assert.deepEqual(observers[0].options, { + childList: true, + subtree: true, + characterData: true, + attributes: true, + attributeFilter: ["data-expinfo", "src", "style"], + }); + observers[0].callback([{ target: card, addedNodes: [] }]); + assert.equal( + observed.length, + 0, + "an unrecognized structure never publishes a fallback snapshot", + ); listeners.get("pagehide")(); + assert.equal(observers[0].disconnected, true); }); -test("rebinds the scoped message observer when its list root is replaced", () => { +test("re-reads a recognized DOM field when its text changes in place", () => { const observers = []; const listeners = new Map(); - const rootParent = { parentElement: null, querySelectorAll: () => [] }; - const firstRoot = { parentElement: rootParent, querySelectorAll: () => [] }; - const secondRoot = { parentElement: rootParent, querySelectorAll: () => [] }; - let wrapper = { parentElement: firstRoot }; + const documentElement = {}; + const fixture = validCardForMutation(); const page = { location: { href: "https://onetalk.alibaba.com/message/default.htm" }, + getComputedStyle: () => ({ display: "block", visibility: "visible" }), document: { + documentElement, querySelectorAll: (selector) => { - assert.equal(selector, ".message-item-wrapper"); - return [wrapper]; + if (selector === ".contact-item-container.selected[data-cid]") + return [{ getAttribute: () => "conversation-1" }]; + if (selector === ".session-rich-content.card") return [fixture.card]; + assert.fail(`unexpected selector: ${selector}`); }, }, MutationObserver: class { constructor(callback) { this.callback = callback; - this.targets = []; + this.options = null; observers.push(this); } - - observe(target) { - this.targets.push(target); + observe(target, options) { + assert.equal(target, documentElement); + this.options = options; } - disconnect() {} }, addEventListener: (type, listener) => listeners.set(type, listener), }; + const observed = []; + installOneTalkRenderedCardObserver(page, (observations) => observed.push(...observations)); + assert.equal(observed.length, 1); - installOneTalkRenderedCardObserver(page, () => undefined); - assert.ok(observers[0].targets.includes(firstRoot)); + fixture.statusNode.textContent = "Updated status"; + observers[0].callback([{ target: fixture.statusNode, addedNodes: [] }]); - wrapper = { parentElement: secondRoot }; - observers[0].callback([]); - - assert.ok(observers[0].targets.includes(secondRoot)); - listeners.get("pagehide")(); -}); - -test("reads a card immediately when its wrapper subtree changes", async () => { - const originalElement = globalThis.Element; - class TestElement { - matches() { - return false; - } - - closest() { - return this.parentWrapper ?? null; - } - - querySelectorAll() { - return []; - } - } - globalThis.Element = TestElement; - - try { - const observers = []; - const listeners = new Map(); - const templateFiber = { memoizedProps: {} }; - const wrapper = new TestElement(); - const root = { - parentElement: null, - querySelectorAll: (selector) => { - assert.equal(selector, ".message-item-wrapper"); - return [wrapper]; - }, - contains: (node) => node === wrapper, - }; - wrapper.parentElement = root; - wrapper.parentWrapper = wrapper; - wrapper.__reactFiber$fixture = { - memoizedProps: { - itemData: { - messageId: "message-1", - conversationCode: "conversation-1", - messageType: "rec", - sendTime: 1_700_000_000_000, - msgType: 10010, - originalData: { cardType: 9 }, - }, - }, - child: templateFiber, - }; - const page = { - document: { - querySelectorAll: (selector) => { - if (selector === ".message-item-wrapper") return [wrapper]; - if (selector === ".contact-item-container.selected[data-cid]") { - return [{ getAttribute: () => "conversation-1" }]; - } - assert.fail(`unexpected selector: ${selector}`); - }, - }, - MutationObserver: class { - constructor(callback) { - this.callback = callback; - observers.push(this); - } - - observe() {} - - disconnect() {} - }, - addEventListener: (type, listener) => listeners.set(type, listener), - }; - const observations = []; - - installOneTalkRenderedCardObserver(page, (next) => observations.push(...next)); - - templateFiber.memoizedProps = { - data: { - cardTitle: "Order #1", - productInfoList: [ - { productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" }, - ], - orderStatusText: "Paid", - shouldPayAmount: "$10", - shippingAddress: "Hangzhou", - }, - }; - observers[0].callback([{ target: wrapper, addedNodes: [new TestElement()] }]); - - assert.deepEqual( - observations.map((observation) => observation.content.kind), - ["rendered_order"], - ); - listeners.get("pagehide")(); - } finally { - if (originalElement === undefined) delete globalThis.Element; - else globalThis.Element = originalElement; - } -}); - -test("re-reads a wrapper immediately when React changes text inside it", async () => { - const originalElement = globalThis.Element; - class TestElement { - matches() { - return false; - } - - closest() { - return this.parentWrapper ?? null; - } - - querySelectorAll() { - return []; - } - } - globalThis.Element = TestElement; - - try { - const observers = []; - const observeCalls = []; - const listeners = new Map(); - const templateFiber = { memoizedProps: {} }; - const wrapper = new TestElement(); - const textNode = { parentElement: wrapper }; - const root = { - parentElement: null, - querySelectorAll: (selector) => { - assert.equal(selector, ".message-item-wrapper"); - return [wrapper]; - }, - contains: (node) => node === wrapper || node?.parentElement === wrapper, - }; - wrapper.parentElement = root; - wrapper.parentWrapper = wrapper; - wrapper.__reactFiber$fixture = { - memoizedProps: { - itemData: { - messageId: "message-1", - conversationCode: "conversation-1", - messageType: "rec", - sendTime: 1_700_000_000_000, - msgType: 10010, - originalData: { cardType: 9 }, - }, - }, - child: templateFiber, - }; - const page = { - document: { - querySelectorAll: (selector) => { - if (selector === ".message-item-wrapper") return [wrapper]; - if (selector === ".contact-item-container.selected[data-cid]") { - return [{ getAttribute: () => "conversation-1" }]; - } - assert.fail(`unexpected selector: ${selector}`); - }, - }, - MutationObserver: class { - constructor(callback) { - this.callback = callback; - observers.push(this); - } - - observe(target, options) { - observeCalls.push({ target, options }); - } - - disconnect() {} - }, - addEventListener: (type, listener) => listeners.set(type, listener), - }; - const observations = []; - - installOneTalkRenderedCardObserver(page, (next) => observations.push(...next)); - templateFiber.memoizedProps = { - data: { - cardTitle: "Order #1", - productInfoList: [ - { productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" }, - ], - orderStatusText: "Paid", - shouldPayAmount: "$10", - shippingAddress: "Hangzhou", - }, - }; - - observers[0].callback([{ target: textNode, addedNodes: [] }]); - - assert.ok( - observeCalls.some( - ({ target, options }) => - target === root && - options.attributes === true && - options.characterData === true, - ), - ); - assert.deepEqual( - observations.map((observation) => observation.content.kind), - ["rendered_order"], - ); - listeners.get("pagehide")(); - } finally { - if (originalElement === undefined) delete globalThis.Element; - else globalThis.Element = originalElement; - } -}); - -test("re-reads a virtualized wrapper for a new card but deduplicates the same card fingerprint", async () => { - const originalElement = globalThis.Element; - class TestElement { - matches() { - return false; - } - - closest() { - return this.parentWrapper ?? null; - } - - querySelectorAll() { - return []; - } - } - globalThis.Element = TestElement; - - try { - const observers = []; - const listeners = new Map(); - const templateFiber = { memoizedProps: {} }; - const wrapper = new TestElement(); - const root = { - parentElement: null, - querySelectorAll: (selector) => { - assert.equal(selector, ".message-item-wrapper"); - return [wrapper]; - }, - contains: (node) => node === wrapper, - }; - wrapper.parentElement = root; - wrapper.parentWrapper = wrapper; - wrapper.__reactFiber$fixture = { - memoizedProps: { - itemData: { - messageId: "message-1", - conversationCode: "conversation-1", - messageType: "rec", - sendTime: 1_700_000_000_000, - msgType: 10010, - originalData: { cardType: 9 }, - }, - }, - child: templateFiber, - }; - const page = { - document: { - querySelectorAll: (selector) => { - if (selector === ".message-item-wrapper") return [wrapper]; - if (selector === ".contact-item-container.selected[data-cid]") { - return [{ getAttribute: () => "conversation-1" }]; - } - assert.fail(`unexpected selector: ${selector}`); - }, - }, - MutationObserver: class { - constructor(callback) { - this.callback = callback; - observers.push(this); - } - - observe() {} - - disconnect() {} - }, - addEventListener: (type, listener) => listeners.set(type, listener), - }; - const observations = []; - const setOrderTemplate = (title) => { - templateFiber.memoizedProps = { - data: { - cardTitle: title, - productInfoList: [ - { productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" }, - ], - orderStatusText: "Paid", - shouldPayAmount: "$10", - shippingAddress: "Hangzhou", - }, - }; - }; - - setOrderTemplate("Order #1"); - installOneTalkRenderedCardObserver(page, (next) => observations.push(...next)); - await new Promise((resolve) => setTimeout(resolve, 120)); - - wrapper.__reactFiber$fixture.memoizedProps.itemData.messageId = "message-2"; - setOrderTemplate("Order #2"); - observers[0].callback([{ target: wrapper, addedNodes: [new TestElement()] }]); - await new Promise((resolve) => setTimeout(resolve, 120)); - - observers[0].callback([{ target: wrapper, addedNodes: [new TestElement()] }]); - await new Promise((resolve) => setTimeout(resolve, 120)); - - assert.deepEqual( - observations.map((value) => [value.messageId, value.content.title]), - [ - ["message-1", "Order #1"], - ["message-2", "Order #2"], - ], - ); - listeners.get("pagehide")(); - } finally { - if (originalElement === undefined) delete globalThis.Element; - else globalThis.Element = originalElement; - } -}); - -test("rescans the current list every second when a card Fiber becomes ready without a DOM mutation", () => { - const originalElement = globalThis.Element; - const originalSetInterval = globalThis.setInterval; - const originalClearInterval = globalThis.clearInterval; - class TestElement { - matches() { - return false; - } - - closest() { - return this.parentWrapper ?? null; - } - - querySelectorAll() { - return []; - } - } - globalThis.Element = TestElement; - - try { - const observers = []; - const intervals = []; - const clearedIntervals = []; - const listeners = new Map(); - const templateFiber = { memoizedProps: {} }; - const wrapper = new TestElement(); - const root = { - parentElement: null, - querySelectorAll: (selector) => { - assert.equal(selector, ".message-item-wrapper"); - return [wrapper]; - }, - contains: (node) => node === wrapper, - }; - wrapper.parentElement = root; - wrapper.parentWrapper = wrapper; - wrapper.__reactFiber$fixture = { - memoizedProps: { - itemData: { - messageId: "message-1", - conversationCode: "conversation-1", - messageType: "rec", - sendTime: 1_700_000_000_000, - msgType: 10010, - originalData: { cardType: 9 }, - }, - }, - child: templateFiber, - }; - globalThis.setInterval = (callback, delay) => { - intervals.push({ callback, delay }); - return intervals.length; - }; - globalThis.clearInterval = (interval) => clearedIntervals.push(interval); - const page = { - document: { - querySelectorAll: (selector) => { - if (selector === ".message-item-wrapper") return [wrapper]; - if (selector === ".contact-item-container.selected[data-cid]") { - return [{ getAttribute: () => "conversation-1" }]; - } - assert.fail(`unexpected selector: ${selector}`); - }, - }, - MutationObserver: class { - constructor(callback) { - this.callback = callback; - observers.push(this); - } - - observe() {} - - disconnect() {} - }, - addEventListener: (type, listener) => listeners.set(type, listener), - }; - const observations = []; - - installOneTalkRenderedCardObserver(page, (next) => observations.push(...next)); - - assert.deepEqual( - intervals.map((interval) => interval.delay), - [1_000], - ); - templateFiber.memoizedProps = { - data: { - cardTitle: "Order #1", - productInfoList: [ - { productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" }, - ], - orderStatusText: "Paid", - shouldPayAmount: "$10", - shippingAddress: "Hangzhou", - }, - }; - - intervals[0].callback(); - - assert.deepEqual( - observations.map((observation) => observation.content.kind), - ["rendered_order"], - ); - listeners.get("pagehide")(); - assert.deepEqual(clearedIntervals, [1]); - } finally { - if (originalElement === undefined) delete globalThis.Element; - else globalThis.Element = originalElement; - globalThis.setInterval = originalSetInterval; - globalThis.clearInterval = originalClearInterval; - } + assert.equal(observed.length, 2); + assert.equal(observed[1].content.status, "Updated status"); }); diff --git a/apps/chrome-extension/test/onetalk-rendered-card-reader.test.js b/apps/chrome-extension/test/onetalk-rendered-card-reader.test.js index 82e458e..efeb3e9 100644 --- a/apps/chrome-extension/test/onetalk-rendered-card-reader.test.js +++ b/apps/chrome-extension/test/onetalk-rendered-card-reader.test.js @@ -1,13 +1,71 @@ -// 验证 MAIN-world React Fiber reader 只投影订单、询盘与商品的批准字段。 +// 验证 DOM-only reader 以可见结构采集命名字段,而不是以模板 ID 准入。 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"; +import { readOneTalkRenderedCardFromDom } from "../src/onetalk/main-page/card-observer/dom-card-reader.ts"; -const imageUrl = "https://img.alicdn.com/card-product.jpg"; -const productUrl = "https://chinese.alibaba.com/product-detail/widget-12345.html"; +const node = ({ + text = null, + imageUrl = null, + viewName = null, + classNames = [], + children = [], +} = {}) => { + const value = { + childNodes: children, + textContent: text, + parentElement: null, + classNames: new Set(classNames), + getAttribute: (name) => { + if (name === "src") return imageUrl; + if (name === "view-name") return viewName; + return null; + }, + querySelectorAll(selector) { + const matches = []; + const visit = (candidate) => { + if ( + (selector === "img[src]" && candidate.getAttribute("src")) || + (selector.startsWith(".") && candidate.classNames?.has(selector.slice(1))) + ) + matches.push(candidate); + for (const child of candidate.childNodes ?? []) visit(child); + }; + for (const child of this.childNodes) visit(child); + return matches; + }, + }; + for (const child of children) child.parentElement = value; + return value; +}; + +const fieldRow = (label, value) => { + const labelNode = node({ text: label, viewName: "TextView" }); + const valueNode = node({ text: value, viewName: "TextView" }); + return node({ children: [labelNode, valueNode] }); +}; + +const productStructure = () => { + const minimumOrder = node({ + children: [ + node({ text: "Minimum order", viewName: "TextView" }), + node({ text: "500", viewName: "TextView" }), + node({ text: "pieces", viewName: "TextView" }), + ], + }); + const productOwner = node({ + classNames: ["dx-event-node"], + children: [ + node({ text: "Product title", viewName: "TextView" }), + node({ text: "US $10.00", viewName: "TextView" }), + minimumOrder, + node({ imageUrl: "https://img.alicdn.com/product.jpg" }), + ], + }); + return node({ classNames: ["tpl-wrapper"], children: [productOwner] }); +}; const pageFor = ({ conversationId = "conversation-1", selectionReads = [conversationId] } = {}) => { let reads = 0; @@ -18,351 +76,320 @@ const pageFor = ({ conversationId = "conversation-1", selectionReads = [conversa 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) }] - : []; + return selected ? [{ getAttribute: () => selected }] : []; }, }, + getComputedStyle: () => ({ display: "block", visibility: "visible" }), }; }; -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 cardFor = ( + root, + { cardInfo = { messageId: "message-1" }, wrapperInfo = { messageId: "message-1" } } = {}, +) => { + const wrapper = { getAttribute: () => JSON.stringify(wrapperInfo) }; + const card = { + closest: (selector) => { + assert.equal(selector, ".message-item-wrapper[data-expinfo]"); + return wrapper; }, - }; - const fiber = { memoizedProps: { itemData: item }, child: templateFiber }; - return { - __reactFiber$fixture: fiber, - querySelectorAll: (selector) => { - assert.equal(selector, "*"); - return []; + matches: (selector) => selector === "[data-expinfo]", + getAttribute: (name) => (name === "data-expinfo" ? JSON.stringify(cardInfo) : null), + querySelector: (selector) => { + if (selector === "[data-expinfo]") return card; + if (selector === ".tpl-wrapper") return root; + assert.fail(`unexpected selector: ${selector}`); }, + querySelectorAll: root.querySelectorAll.bind(root), + childNodes: root.childNodes, }; + root.parentElement = card; + return card; }; -const readWithFixedTime = (page, wrapper) => { +const orderVariant = ({ templateId = null } = {}) => { + const product = node({ + classNames: ["list-item"], + children: [node({ imageUrl: "https://img.alicdn.com/order.jpg" })], + }); + const root = node({ + classNames: ["tpl-wrapper"], + children: [ + node({ text: "订单", viewName: "TextView" }), + product, + fieldRow("Product count", "3 items"), + fieldRow("订单状态", "Paid"), + fieldRow("Total", "US $10.00"), + fieldRow("Delivery date", "2026-09-16"), + fieldRow("Shipping address", "1 Market Street"), + fieldRow("Colour", "Blue"), + ], + }); + root.getAttribute = (name) => (name === "data-tpl-id" ? templateId : null); + return root; +}; + +const readWithFixedTime = (page, card) => { const previousNow = Date.now; Date.now = () => 1_700_000_000_123; try { - return readOneTalkRenderedCard(page, wrapper); + return readOneTalkRenderedCardFromDom(page, card); } finally { Date.now = previousNow; } }; -test("reads an order that uses productName into the exact normalized card and base evidence", () => { +test("collects a named order structure without using its unknown template ID", () => { 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" }, + image: "https://img.alicdn.com/order.jpg", + productCount: { display: "3 items", value: 3 }, + status: "Paid", + total: "US $10.00", + deliveryDate: "2026-09-16", + shippingAddress: "1 Market Street", }; - const read = readWithFixedTime( + const result = 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" }, - }, - }), + cardFor(orderVariant({ templateId: "3829708538501" })), ); - - 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(result, { + conversationId: "conversation-1", + messageId: "message-1", + content, + contentFingerprint: createOneTalkRenderedCardContentFingerprint(content), + observedAtMs: 1_700_000_000_123, }); }); -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", - }, +test("collects an order image beside a direct product-count display without list-item markup", () => { + const root = node({ + classNames: ["tpl-wrapper"], + children: [ + node({ text: "订单", viewName: "TextView" }), + node({ imageUrl: "https://img.alicdn.com/order-chevron.jpg" }), + node({ + children: [ + node({ + children: [ + node({ imageUrl: "https://img.alicdn.com/order-product.jpg" }), + node({ imageUrl: "https://img.alicdn.com/order-product-second.jpg" }), + ], + }), + node({ + children: [ + node({ + children: [ + node({ + text: "5 products", + viewName: "TextView", + }), + ], + }), + ], + }), ], - 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, - }, + }), + fieldRow("订单状态", "Pending"), + ], }); -}); - -test("reads a product only through the product renderer and omits every raw or store-image field", () => { - const content = { + assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.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, - }, + image: "https://img.alicdn.com/order-product.jpg", + productCount: { display: "5 products", value: 5 }, + status: "Pending", }); - 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("omits unrecognized inquiry rows instead of emitting attributes", () => { + const root = node({ + classNames: ["tpl-wrapper"], + children: [ + fieldRow("Colour", "Blue"), + node({ text: "详细需求", viewName: "TextView" }), + node({ classNames: ["description-container"], text: "Need delivery this week" }), + node({ classNames: ["dx-event-node"], text: "View details" }), + ], + }); + assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, { + version: 1, + requirementText: "Need delivery this week", + }); }); -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("collects an inquiry product, purchase quantity, and requirement without its UI ID", () => { + const root = node({ + classNames: ["tpl-wrapper"], + children: [ + node({ + classNames: ["list-item"], + children: [ + node({ imageUrl: "https://img.alicdn.com/inquiry-product.jpg" }), + node({ text: "Inquiry product title", viewName: "TextView" }), + ], + }), + fieldRow("采购数量", "500 Piece/Pieces"), + node({ text: "详细需求", viewName: "TextView" }), + node({ + classNames: ["description-container"], + text: "Need custom sizing\nSecond line", + }), + fieldRow("ID:", "13894257698"), + ], + }); + assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, { + version: 1, + title: "Inquiry product title", + image: "https://img.alicdn.com/inquiry-product.jpg", + purchaseQuantity: "500 Piece/Pieces", + requirementText: "Need custom sizing\nSecond line", + }); }); -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); +test("preserves rendered line breaks in an inquiry requirement", () => { + const requirement = node({ + classNames: ["description-container"], + text: "Need custom sizingSecond line", + }); + requirement.innerText = "Need custom sizing\nSecond line"; + const root = node({ + classNames: ["tpl-wrapper"], + children: [node({ text: "详细需求", viewName: "TextView" }), requirement], + }); + assert.equal( + readWithFixedTime(pageFor(), cardFor(root))?.content.requirementText, + "Need custom sizing\nSecond line", + ); +}); + +test("rejects an inquiry-shaped card without the detailed-requirement anchor", () => { + const root = node({ + classNames: ["tpl-wrapper"], + children: [ + fieldRow("Colour", "Blue"), + node({ classNames: ["description-container"], text: "Need delivery this week" }), + ], + }); + assert.equal(readWithFixedTime(pageFor(), cardFor(root)), null); +}); + +test("rejects a card that matches more than one parser anchor", () => { + const product = productStructure(); + const root = node({ + classNames: ["tpl-wrapper"], + children: [...product.childNodes, fieldRow("订单状态", "Pending")], + }); + assert.equal(readWithFixedTime(pageFor(), cardFor(root)), null); +}); + +test("rejects cards with a repeated parser anchor", () => { + const order = orderVariant(); + order.childNodes.push(fieldRow("订单状态", "Duplicate")); + assert.equal(readWithFixedTime(pageFor(), cardFor(order)), null); + + const inquiry = node({ + classNames: ["tpl-wrapper"], + children: [ + node({ text: "详细需求", viewName: "TextView" }), + node({ text: "详细需求", viewName: "TextView" }), + node({ classNames: ["description-container"], text: "Need details" }), + ], + }); + assert.equal(readWithFixedTime(pageFor(), cardFor(inquiry)), null); + + const product = productStructure(); + product.childNodes.push(node({ text: "Minimum order", viewName: "TextView" })); + assert.equal(readWithFixedTime(pageFor(), cardFor(product)), null); +}); + +test("collects a product owner with view-name TextViews and no template metadata", () => { + assert.deepEqual(readWithFixedTime(pageFor(), cardFor(productStructure()))?.content, { + version: 1, + title: "Product title", + price: "US $10.00", + minimumOrder: "500 pieces", + image: "https://img.alicdn.com/product.jpg", + }); +}); + +test("collects a nested product variant around its minimum-order anchor", () => { + const textContainer = (text) => + node({ + children: [node({ text, viewName: "TextView" })], + }); + const root = node({ + classNames: ["tpl-wrapper"], + children: [ + node({ + classNames: ["dx-event-node"], + children: [ + node({ imageUrl: "https://img.alicdn.com/variant-product.jpg" }), + textContainer("Super"), + textContainer("Variant product title"), + textContainer("$4.50-5.09"), + node({ + children: [ + node({ text: "最小订购量", viewName: "TextView" }), + node({ text: "10", viewName: "TextView" }), + node({ text: "Pieces", viewName: "TextView" }), + ], + }), + textContainer("无忧退货"), + ], + }), + ], + }); + assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, { + version: 1, + title: "Variant product title", + price: "$4.50-5.09", + minimumOrder: "10 Pieces", + image: "https://img.alicdn.com/variant-product.jpg", + }); +}); + +test("does not project unrecognized non-inquiry rows or action labels", () => { + const root = node({ + classNames: ["tpl-wrapper"], + children: [ + fieldRow( + "Women's Elegant Fashionable PU Leather Crossbody Bag", + "$4.50-5.09最小订购量: 10Pieces无忧退货", + ), + node({ classNames: ["dx-event-node"], text: "View details" }), + ], + }); + assert.equal(readWithFixedTime(pageFor(), cardFor(root)), null); +}); + +test("rejects identity conflicts, unsafe images, invisible nodes, unstable selection, and empty structures", () => { + assert.equal( + readWithFixedTime(pageFor(), cardFor(node({ classNames: ["tpl-wrapper"] }))), + null, + ); + assert.equal( + readWithFixedTime( + pageFor(), + cardFor(orderVariant(), { wrapperInfo: { messageId: "other" } }), + ), + null, + ); + const unsafe = orderVariant(); + unsafe.childNodes[1].childNodes[0].getAttribute = (name) => + name === "src" ? "https://img.alicdn.com/order.jpg?token=secret" : null; + assert.equal( + readWithFixedTime(pageFor(), cardFor(unsafe))?.content.image, + undefined, + "an unsafe optional image group must not suppress recognized text fields", + ); + const hidden = orderVariant(); + hidden.childNodes[3].childNodes[1].hidden = true; + assert.equal(readWithFixedTime(pageFor(), cardFor(hidden))?.content.status, undefined); + assert.equal( + readWithFixedTime( + pageFor({ selectionReads: ["conversation-1", "conversation-2"] }), + cardFor(orderVariant()), + ), + null, + ); }); diff --git a/apps/chrome-extension/test/onetalk-sync-storage.test.js b/apps/chrome-extension/test/onetalk-sync-storage.test.js index 80de681..5a3a8a0 100644 --- a/apps/chrome-extension/test/onetalk-sync-storage.test.js +++ b/apps/chrome-extension/test/onetalk-sync-storage.test.js @@ -451,8 +451,6 @@ test("preserves v9 rendered-card records and reads pending ACKs through the acco content, contentFingerprint: createOneTalkRenderedCardContentFingerprint(content), observedAtMs: 100, - baseDirection: "received", - baseSentAtMs: 99, status: "pending_ack", firstObservedAt: 100, updatedAt: 100, @@ -506,7 +504,6 @@ test("clears only one conversation history ledger after its transaction commits" contentFingerprint: createOneTalkRenderedCardContentFingerprint(renderedCardContent), observedAtMs: 500, }, - baseEvidence: { direction: "received", sentAtMs: 499 }, observedAt: 500, }); for (const [channelAccountId, conversationId, messageId] of [ @@ -523,7 +520,6 @@ test("clears only one conversation history ledger after its transaction commits" createOneTalkRenderedCardContentFingerprint(renderedCardContent), observedAtMs: 500, }, - baseEvidence: { direction: "received", sentAtMs: 499 }, observedAt: 500, }); } @@ -643,20 +639,19 @@ test("preserves the first rendered-card snapshot across concurrent same-key obse store.observe({ channelAccountId: "account-1", observation: first, - baseEvidence: { direction: "received", sentAtMs: 499 }, observedAt: 500, }), store.observe({ channelAccountId: "account-1", observation: second, - baseEvidence: { direction: "received", sentAtMs: 499 }, observedAt: 501, }), ]); const record = await store.get("account-1", "conversation-1", "message-1"); assert.deepEqual(record.content, firstContent); - assert.equal(record.lastConflictingFingerprint, second.contentFingerprint); + assert.equal("baseDirection" in record, false); + assert.equal("baseSentAtMs" in record, false); }); test("keeps the first pending ACK correlation while serializing same-key ledger writes", async () => { @@ -681,8 +676,7 @@ test("keeps the first pending ACK correlation while serializing same-key ledger observedAtMs: 500, }; const second = { ...first, observedAtMs: 501 }; - const baseEvidence = { direction: "received", sentAtMs: 499 }; - await store.observe({ channelAccountId: "account-1", observation: first, baseEvidence }); + await store.observe({ channelAccountId: "account-1", observation: first }); await store.markSent({ channelAccountId: "account-1", conversationId: first.conversationId, @@ -692,7 +686,7 @@ test("keeps the first pending ACK correlation while serializing same-key ledger requestId: "request-1", }); await Promise.all([ - store.observe({ channelAccountId: "account-1", observation: second, baseEvidence }), + store.observe({ channelAccountId: "account-1", observation: second }), store.markAcknowledged({ channelAccountId: "account-1", conversationId: first.conversationId, diff --git a/apps/server/drizzle/0015_wild_toad_men.sql b/apps/server/drizzle/0015_wild_toad_men.sql new file mode 100644 index 0000000..521a746 --- /dev/null +++ b/apps/server/drizzle/0015_wild_toad_men.sql @@ -0,0 +1,4 @@ +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "first_confirmed_at";--> statement-breakpoint +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_observed_at";--> statement-breakpoint +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_conflicting_fingerprint";--> statement-breakpoint +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_conflict_observed_at_ms"; \ No newline at end of file diff --git a/apps/server/drizzle/0016_optimal_imperial_guard.sql b/apps/server/drizzle/0016_optimal_imperial_guard.sql new file mode 100644 index 0000000..ed56e00 --- /dev/null +++ b/apps/server/drizzle/0016_optimal_imperial_guard.sql @@ -0,0 +1,2 @@ +ALTER TABLE "onetalk_rendered_card_content" DROP CONSTRAINT "onetalk_rendered_card_content_v1_chk";--> statement-breakpoint +ALTER TABLE "onetalk_rendered_card_content" ADD CONSTRAINT "onetalk_rendered_card_content_v1_chk" CHECK (jsonb_typeof("onetalk_rendered_card_content"."rendered_card_content") = 'object' and ("onetalk_rendered_card_content"."rendered_card_content" ->> 'version') = '1' and (("onetalk_rendered_card_content"."rendered_card_content" ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order') or not ("onetalk_rendered_card_content"."rendered_card_content" ? 'kind'))); diff --git a/apps/server/drizzle/meta/0015_snapshot.json b/apps/server/drizzle/meta/0015_snapshot.json new file mode 100644 index 0000000..ed719a6 --- /dev/null +++ b/apps/server/drizzle/meta/0015_snapshot.json @@ -0,0 +1,951 @@ +{ + "id": "07d3d83f-18e4-4567-b86d-811da89dbf0c", + "prevId": "4a1b7f8f-de1b-411e-939b-3e2907d39c09", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.onetalk_buyer_fact": { + "name": "onetalk_buyer_fact", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "buyer_tags": { + "name": "buyer_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "buyer_features": { + "name": "buyer_features", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_date": { + "name": "registration_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_website": { + "name": "company_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags_state": { + "name": "tags_state", + "type": "onetalk_buyer_fact_source_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "features_state": { + "name": "features_state", + "type": "onetalk_buyer_fact_source_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "contact_details_state": { + "name": "contact_details_state", + "type": "onetalk_buyer_fact_source_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tags_last_attempted_at": { + "name": "tags_last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "features_last_attempted_at": { + "name": "features_last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "contact_details_last_attempted_at": { + "name": "contact_details_last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tags_confirmed_at": { + "name": "tags_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "features_confirmed_at": { + "name": "features_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contact_details_confirmed_at": { + "name": "contact_details_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tags_error_code": { + "name": "tags_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features_error_code": { + "name": "features_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_details_error_code": { + "name": "contact_details_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fact_fingerprint": { + "name": "fact_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_buyer_fact_channel_account_id_conversation_id_pk": { + "name": "onetalk_buyer_fact_channel_account_id_conversation_id_pk", + "columns": ["channel_account_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_contact_profile": { + "name": "onetalk_contact_profile", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ali_id": { + "name": "ali_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "login_id": { + "name": "login_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_time_zone": { + "name": "current_time_zone", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "service_type": { + "name": "service_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at_ms": { + "name": "observed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "profile_fingerprint": { + "name": "profile_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "observation_status": { + "name": "observation_status", + "type": "onetalk_contact_profile_observation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_contact_profile_channel_account_id_conversation_id_pk": { + "name": "onetalk_contact_profile_channel_account_id_conversation_id_pk", + "columns": ["channel_account_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_conversation": { + "name": "onetalk_conversation", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_ids": { + "name": "participant_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "biz_type": { + "name": "biz_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_type": { + "name": "conversation_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_time_ms": { + "name": "join_time_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modify_time_ms": { + "name": "modify_time_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_message_at_ms": { + "name": "last_message_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_contact_time_ms": { + "name": "last_contact_time_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_preview": { + "name": "message_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_phase": { + "name": "sync_phase", + "type": "onetalk_sync_phase", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "sync_result": { + "name": "sync_result", + "type": "onetalk_sync_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "latest_message_id": { + "name": "latest_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "history_generation": { + "name": "history_generation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "history_complete": { + "name": "history_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_discovered_at": { + "name": "first_discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "anchor_updated_at": { + "name": "anchor_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "onetalk_conversation_last_observed_idx": { + "name": "onetalk_conversation_last_observed_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onetalk_conversation_contact_time_idx": { + "name": "onetalk_conversation_contact_time_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_contact_time_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_conversation_channel_account_id_conversation_id_pk": { + "name": "onetalk_conversation_channel_account_id_conversation_id_pk", + "columns": ["channel_account_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_message": { + "name": "onetalk_message", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mind_user_id": { + "name": "mind_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "onetalk_message_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "observation_type": { + "name": "observation_type", + "type": "onetalk_observation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sent_at_ms": { + "name": "sent_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "participant_ids": { + "name": "participant_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "read_status": { + "name": "read_status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "message_status": { + "name": "message_status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unread_count": { + "name": "unread_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "first_observed_at": { + "name": "first_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onetalk_message_conversation_time_idx": { + "name": "onetalk_message_conversation_time_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onetalk_message_sender_idx": { + "name": "onetalk_message_sender_idx", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_message_channel_account_id_conversation_id_message_id_pk": { + "name": "onetalk_message_channel_account_id_conversation_id_message_id_pk", + "columns": ["channel_account_id", "conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "onetalk_message_content_v1_chk": { + "name": "onetalk_message_content_v1_chk", + "value": "jsonb_typeof(\"onetalk_message\".\"content\") = 'object' and (\"onetalk_message\".\"content\" ->> 'version') = '1' and (\"onetalk_message\".\"content\" ->> 'kind') in ('text', 'image', 'file', 'business_card', 'inquiry', 'order', 'product') and ((\"onetalk_message\".\"content\" ->> 'kind') <> 'business_card' or \"onetalk_message\".\"content\" = '{\"version\":1,\"kind\":\"business_card\"}'::jsonb)" + } + }, + "isRLSEnabled": false + }, + "public.onetalk_message_anomaly": { + "name": "onetalk_message_anomaly", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anomaly_type": { + "name": "anomaly_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mind_user_id": { + "name": "mind_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "missing_fields": { + "name": "missing_fields", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "observation_source": { + "name": "observation_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "onetalk_anomaly_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "onetalk_message_anomaly_fingerprint_uidx": { + "name": "onetalk_message_anomaly_fingerprint_uidx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onetalk_message_anomaly_scope_idx": { + "name": "onetalk_message_anomaly_scope_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_rendered_card_content": { + "name": "onetalk_rendered_card_content", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendered_card_content": { + "name": "rendered_card_content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rendered_card_content_fingerprint": { + "name": "rendered_card_content_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendered_card_observed_at_ms": { + "name": "rendered_card_observed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "conflict_count": { + "name": "conflict_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "onetalk_rendered_card_content_scope_idx": { + "name": "onetalk_rendered_card_content_scope_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_rendered_card_content_channel_account_id_conversation_id_message_id_pk": { + "name": "onetalk_rendered_card_content_channel_account_id_conversation_id_message_id_pk", + "columns": ["channel_account_id", "conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "onetalk_rendered_card_content_v1_chk": { + "name": "onetalk_rendered_card_content_v1_chk", + "value": "jsonb_typeof(\"onetalk_rendered_card_content\".\"rendered_card_content\") = 'object' and (\"onetalk_rendered_card_content\".\"rendered_card_content\" ->> 'version') = '1' and (\"onetalk_rendered_card_content\".\"rendered_card_content\" ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order')" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.onetalk_anomaly_status": { + "name": "onetalk_anomaly_status", + "schema": "public", + "values": ["open", "resolved", "ignored"] + }, + "public.onetalk_buyer_fact_source_state": { + "name": "onetalk_buyer_fact_source_state", + "schema": "public", + "values": ["pending", "confirmed", "failed"] + }, + "public.onetalk_contact_profile_observation_status": { + "name": "onetalk_contact_profile_observation_status", + "schema": "public", + "values": ["confirmed", "partial"] + }, + "public.onetalk_message_direction": { + "name": "onetalk_message_direction", + "schema": "public", + "values": ["sent", "received"] + }, + "public.onetalk_observation_type": { + "name": "onetalk_observation_type", + "schema": "public", + "values": ["new", "history"] + }, + "public.onetalk_sync_phase": { + "name": "onetalk_sync_phase", + "schema": "public", + "values": ["initial", "incremental"] + }, + "public.onetalk_sync_result": { + "name": "onetalk_sync_result", + "schema": "public", + "values": ["succeeded", "succeeded_with_anomalies", "failed", "incomplete"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/drizzle/meta/0016_snapshot.json b/apps/server/drizzle/meta/0016_snapshot.json new file mode 100644 index 0000000..3e98b81 --- /dev/null +++ b/apps/server/drizzle/meta/0016_snapshot.json @@ -0,0 +1,951 @@ +{ + "id": "741594e6-e835-4cd2-b688-6f53b3064552", + "prevId": "07d3d83f-18e4-4567-b86d-811da89dbf0c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.onetalk_buyer_fact": { + "name": "onetalk_buyer_fact", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "buyer_tags": { + "name": "buyer_tags", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "buyer_features": { + "name": "buyer_features", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "registration_date": { + "name": "registration_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_website": { + "name": "company_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tags_state": { + "name": "tags_state", + "type": "onetalk_buyer_fact_source_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "features_state": { + "name": "features_state", + "type": "onetalk_buyer_fact_source_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "contact_details_state": { + "name": "contact_details_state", + "type": "onetalk_buyer_fact_source_state", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "tags_last_attempted_at": { + "name": "tags_last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "features_last_attempted_at": { + "name": "features_last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "contact_details_last_attempted_at": { + "name": "contact_details_last_attempted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tags_confirmed_at": { + "name": "tags_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "features_confirmed_at": { + "name": "features_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "contact_details_confirmed_at": { + "name": "contact_details_confirmed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "tags_error_code": { + "name": "tags_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "features_error_code": { + "name": "features_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "contact_details_error_code": { + "name": "contact_details_error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fact_fingerprint": { + "name": "fact_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_buyer_fact_channel_account_id_conversation_id_pk": { + "name": "onetalk_buyer_fact_channel_account_id_conversation_id_pk", + "columns": ["channel_account_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_contact_profile": { + "name": "onetalk_contact_profile", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ali_id": { + "name": "ali_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "login_id": { + "name": "login_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "country_code": { + "name": "country_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "current_time_zone": { + "name": "current_time_zone", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "service_type": { + "name": "service_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "observed_at_ms": { + "name": "observed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "profile_fingerprint": { + "name": "profile_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "observation_status": { + "name": "observation_status", + "type": "onetalk_contact_profile_observation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "received_at": { + "name": "received_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_contact_profile_channel_account_id_conversation_id_pk": { + "name": "onetalk_contact_profile_channel_account_id_conversation_id_pk", + "columns": ["channel_account_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_conversation": { + "name": "onetalk_conversation", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_ids": { + "name": "participant_ids", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "biz_type": { + "name": "biz_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_type": { + "name": "conversation_type", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "conversation_kind": { + "name": "conversation_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "join_time_ms": { + "name": "join_time_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "modify_time_ms": { + "name": "modify_time_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_message_at_ms": { + "name": "last_message_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "last_contact_time_ms": { + "name": "last_contact_time_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "message_preview": { + "name": "message_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_phase": { + "name": "sync_phase", + "type": "onetalk_sync_phase", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "sync_result": { + "name": "sync_result", + "type": "onetalk_sync_result", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'incomplete'" + }, + "latest_message_id": { + "name": "latest_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "history_generation": { + "name": "history_generation", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'initial'" + }, + "history_complete": { + "name": "history_complete", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "message_count": { + "name": "message_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "first_discovered_at": { + "name": "first_discovered_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "anchor_updated_at": { + "name": "anchor_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "onetalk_conversation_last_observed_idx": { + "name": "onetalk_conversation_last_observed_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_observed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onetalk_conversation_contact_time_idx": { + "name": "onetalk_conversation_contact_time_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_contact_time_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_conversation_channel_account_id_conversation_id_pk": { + "name": "onetalk_conversation_channel_account_id_conversation_id_pk", + "columns": ["channel_account_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_message": { + "name": "onetalk_message", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mind_user_id": { + "name": "mind_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "onetalk_message_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "observation_type": { + "name": "observation_type", + "type": "onetalk_observation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "sent_at_ms": { + "name": "sent_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "participant_ids": { + "name": "participant_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "read_status": { + "name": "read_status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "message_status": { + "name": "message_status", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "unread_count": { + "name": "unread_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "first_observed_at": { + "name": "first_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_observed_at": { + "name": "last_observed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "onetalk_message_conversation_time_idx": { + "name": "onetalk_message_conversation_time_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sent_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onetalk_message_sender_idx": { + "name": "onetalk_message_sender_idx", + "columns": [ + { + "expression": "sender_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_message_channel_account_id_conversation_id_message_id_pk": { + "name": "onetalk_message_channel_account_id_conversation_id_message_id_pk", + "columns": ["channel_account_id", "conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "onetalk_message_content_v1_chk": { + "name": "onetalk_message_content_v1_chk", + "value": "jsonb_typeof(\"onetalk_message\".\"content\") = 'object' and (\"onetalk_message\".\"content\" ->> 'version') = '1' and (\"onetalk_message\".\"content\" ->> 'kind') in ('text', 'image', 'file', 'business_card', 'inquiry', 'order', 'product') and ((\"onetalk_message\".\"content\" ->> 'kind') <> 'business_card' or \"onetalk_message\".\"content\" = '{\"version\":1,\"kind\":\"business_card\"}'::jsonb)" + } + }, + "isRLSEnabled": false + }, + "public.onetalk_message_anomaly": { + "name": "onetalk_message_anomaly", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anomaly_type": { + "name": "anomaly_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "mind_user_id": { + "name": "mind_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "device_id": { + "name": "device_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "missing_fields": { + "name": "missing_fields", + "type": "text[]", + "primaryKey": false, + "notNull": true + }, + "observation_source": { + "name": "observation_source", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "onetalk_anomaly_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "occurrence_count": { + "name": "occurrence_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "first_seen_at": { + "name": "first_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "resolved_at": { + "name": "resolved_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "onetalk_message_anomaly_fingerprint_uidx": { + "name": "onetalk_message_anomaly_fingerprint_uidx", + "columns": [ + { + "expression": "fingerprint", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "onetalk_message_anomaly_scope_idx": { + "name": "onetalk_message_anomaly_scope_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.onetalk_rendered_card_content": { + "name": "onetalk_rendered_card_content", + "schema": "", + "columns": { + "channel_account_id": { + "name": "channel_account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendered_card_content": { + "name": "rendered_card_content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "rendered_card_content_fingerprint": { + "name": "rendered_card_content_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rendered_card_observed_at_ms": { + "name": "rendered_card_observed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "conflict_count": { + "name": "conflict_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "onetalk_rendered_card_content_scope_idx": { + "name": "onetalk_rendered_card_content_scope_idx", + "columns": [ + { + "expression": "channel_account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "onetalk_rendered_card_content_channel_account_id_conversation_id_message_id_pk": { + "name": "onetalk_rendered_card_content_channel_account_id_conversation_id_message_id_pk", + "columns": ["channel_account_id", "conversation_id", "message_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "onetalk_rendered_card_content_v1_chk": { + "name": "onetalk_rendered_card_content_v1_chk", + "value": "jsonb_typeof(\"onetalk_rendered_card_content\".\"rendered_card_content\") = 'object' and (\"onetalk_rendered_card_content\".\"rendered_card_content\" ->> 'version') = '1' and ((\"onetalk_rendered_card_content\".\"rendered_card_content\" ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order') or not (\"onetalk_rendered_card_content\".\"rendered_card_content\" ? 'kind'))" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.onetalk_anomaly_status": { + "name": "onetalk_anomaly_status", + "schema": "public", + "values": ["open", "resolved", "ignored"] + }, + "public.onetalk_buyer_fact_source_state": { + "name": "onetalk_buyer_fact_source_state", + "schema": "public", + "values": ["pending", "confirmed", "failed"] + }, + "public.onetalk_contact_profile_observation_status": { + "name": "onetalk_contact_profile_observation_status", + "schema": "public", + "values": ["confirmed", "partial"] + }, + "public.onetalk_message_direction": { + "name": "onetalk_message_direction", + "schema": "public", + "values": ["sent", "received"] + }, + "public.onetalk_observation_type": { + "name": "onetalk_observation_type", + "schema": "public", + "values": ["new", "history"] + }, + "public.onetalk_sync_phase": { + "name": "onetalk_sync_phase", + "schema": "public", + "values": ["initial", "incremental"] + }, + "public.onetalk_sync_result": { + "name": "onetalk_sync_result", + "schema": "public", + "values": ["succeeded", "succeeded_with_anomalies", "failed", "incomplete"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index 7d3755a..01d2623 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -106,6 +106,20 @@ "when": 1789399393296, "tag": "0014_youthful_iron_lad", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1789532281000, + "tag": "0015_wild_toad_men", + "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1789534247470, + "tag": "0016_optimal_imperial_guard", + "breakpoints": true } ] } diff --git a/apps/server/src/database/schema/onetalk.ts b/apps/server/src/database/schema/onetalk.ts index 31a749f..8e1740d 100644 --- a/apps/server/src/database/schema/onetalk.ts +++ b/apps/server/src/database/schema/onetalk.ts @@ -124,7 +124,7 @@ export const onetalkMessage = pgTable( ], ); -/** 与基础消息分离的只读 React 渲染卡片补全事实。 */ +/** 与基础消息分离的只读 DOM 渲染卡片补全事实。 */ export const onetalkRenderedCardContent = pgTable( "onetalk_rendered_card_content", { @@ -144,20 +144,8 @@ export const onetalkRenderedCardContent = pgTable( renderedCardObservedAtMs: bigint("rendered_card_observed_at_ms", { mode: "number", }).notNull(), - /** 首次接受该补全事实的服务端审计时间。 */ - firstConfirmedAt: timestamp("first_confirmed_at", { withTimezone: true }) - .defaultNow() - .notNull(), - /** 最近一次收到同一补全事实的服务端审计时间。 */ - lastObservedAt: timestamp("last_observed_at", { withTimezone: true }) - .defaultNow() - .notNull(), /** 不同内容尝试的次数;首个接受快照不可覆盖。 */ conflictCount: integer("conflict_count").notNull().default(0), - /** 最近一次冲突补全的指纹,仅用于诊断。 */ - lastConflictingFingerprint: text("last_conflicting_fingerprint"), - /** 最近一次冲突 observation 的页面时间。 */ - lastConflictObservedAtMs: bigint("last_conflict_observed_at_ms", { mode: "number" }), }, (table) => [ primaryKey({ columns: [table.channelAccountId, table.conversationId, table.messageId] }), @@ -168,7 +156,7 @@ export const onetalkRenderedCardContent = pgTable( ), check( "onetalk_rendered_card_content_v1_chk", - sql`jsonb_typeof(${table.renderedCardContent}) = 'object' and (${table.renderedCardContent} ->> 'version') = '1' and (${table.renderedCardContent} ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order')`, + sql`jsonb_typeof(${table.renderedCardContent}) = 'object' and (${table.renderedCardContent} ->> 'version') = '1' and ((${table.renderedCardContent} ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order') or not (${table.renderedCardContent} ? 'kind'))`, ), ], ); diff --git a/apps/server/src/onetalk/read-projection.ts b/apps/server/src/onetalk/read-projection.ts index a187ede..fa864c7 100644 --- a/apps/server/src/onetalk/read-projection.ts +++ b/apps/server/src/onetalk/read-projection.ts @@ -4,11 +4,13 @@ import { isOneTalkCenterMessageContent, isOneTalkMessageContent, isOneTalkRenderedCardContent, + isOneTalkRenderedCard, type OneTalkHttpConversation, type OneTalkCenterMessage, type OneTalkMessage, type OneTalkCenterMessageContent, type OneTalkRenderedCardContent, + type OneTalkRenderedCard, } from "@trade-message-center/onetalk-contract"; import type { @@ -108,6 +110,7 @@ const supplementMessageContent = ( if (!isOneTalkRenderedCardContent(renderedCardContent)) { throw new Error("Invalid persisted OneTalk rendered-card supplement"); } + if (isOneTalkRenderedCard(renderedCardContent)) return content; if (renderedCardContent.kind === "rendered_inquiry") { if (content.kind !== "inquiry") { throw new Error("OneTalk rendered-card supplement kind does not match base message"); @@ -157,6 +160,17 @@ const supplementMessageContent = ( }; }; +const renderedCardFor = ( + renderedCardContent: OneTalkRenderedCardContent | undefined, +): OneTalkRenderedCard | undefined => { + if (renderedCardContent === undefined) return undefined; + if (!isOneTalkRenderedCardContent(renderedCardContent)) { + throw new Error("Invalid persisted OneTalk rendered-card supplement"); + } + if (!isOneTalkRenderedCard(renderedCardContent)) return undefined; + return structuredClone(renderedCardContent); +}; + const projectMessageContent = ( content: OneTalkCenterMessageContent, customerProfile?: Pick< @@ -184,6 +198,7 @@ export const toOneTalkCenterMessage = ( >, ): OneTalkCenterMessage => { const content = supplementMessageContent(message.content, renderedCardContent); + const renderedCard = renderedCardFor(renderedCardContent); if (!isOneTalkCenterMessageContent(content)) { throw new Error("Invalid persisted OneTalk message content"); } @@ -195,6 +210,7 @@ export const toOneTalkCenterMessage = ( direction: message.direction, sentAtMs: message.sentAtMs, content: projectMessageContent(content, customerProfile), + ...(renderedCard === undefined ? {} : { renderedCard }), }; }; diff --git a/apps/server/src/onetalk/rendered-card-model.ts b/apps/server/src/onetalk/rendered-card-model.ts index 678c81c..4bb5c21 100644 --- a/apps/server/src/onetalk/rendered-card-model.ts +++ b/apps/server/src/onetalk/rendered-card-model.ts @@ -28,7 +28,6 @@ export type OneTalkRenderedCardRepository = { store: (input: { channelAccountId: string; observation: OneTalkRenderedCardObservation; - receivedAt: Date; commitGuard?: OneTalkCommitGuard; }) => Promise; }; diff --git a/apps/server/src/onetalk/rendered-card-repository.ts b/apps/server/src/onetalk/rendered-card-repository.ts index 20161c9..cb67e48 100644 --- a/apps/server/src/onetalk/rendered-card-repository.ts +++ b/apps/server/src/onetalk/rendered-card-repository.ts @@ -79,8 +79,6 @@ const store = async ( renderedCardContent: input.observation.content, renderedCardContentFingerprint: input.observation.contentFingerprint, renderedCardObservedAtMs: input.observation.observedAtMs, - firstConfirmedAt: input.receivedAt, - lastObservedAt: input.receivedAt, }); input.commitGuard?.assertValid(); const baseMessage = await existingBaseMessageFor(transaction, input); @@ -93,10 +91,6 @@ const store = async ( } const saved = record.renderedCardContent as OneTalkRenderedCardContent; if (isSameOneTalkRenderedCardContent(saved, input.observation.content)) { - await transaction - .update(onetalkRenderedCardContent) - .set({ lastObservedAt: input.receivedAt }) - .where(keyCondition(input)); input.commitGuard?.assertValid(); const baseMessage = await existingBaseMessageFor(transaction, input); input.commitGuard?.assertValid(); @@ -108,11 +102,7 @@ const store = async ( } await transaction .update(onetalkRenderedCardContent) - .set({ - conflictCount: sql`${onetalkRenderedCardContent.conflictCount} + 1`, - lastConflictingFingerprint: input.observation.contentFingerprint, - lastConflictObservedAtMs: input.observation.observedAtMs, - }) + .set({ conflictCount: sql`${onetalkRenderedCardContent.conflictCount} + 1` }) .where(keyCondition(input)); input.commitGuard?.assertValid(); const baseMessage = await existingBaseMessageFor(transaction, input); diff --git a/apps/server/src/onetalk/rendered-card-service.ts b/apps/server/src/onetalk/rendered-card-service.ts index 48efa17..bc56b71 100644 --- a/apps/server/src/onetalk/rendered-card-service.ts +++ b/apps/server/src/onetalk/rendered-card-service.ts @@ -1,7 +1,7 @@ // 编排 OneTalk 渲染卡片补全的受控写入 import { - cloneOneTalkRenderedCardContent, + cloneOneTalkRenderedCard, createOneTalkRenderedCardContentFingerprint, } from "@trade-message-center/onetalk-contract"; import type { @@ -12,10 +12,9 @@ import type { /** 创建仅提交独立 supplement 事实的服务。 */ export const createOneTalkRenderedCardService = ( repository: OneTalkRenderedCardRepository, - now: () => Date = () => new Date(), ): OneTalkRenderedCardService => ({ ingest: async (input) => { - const content = cloneOneTalkRenderedCardContent(input.observation.content); + const content = cloneOneTalkRenderedCard(input.observation.content); return repository.store({ channelAccountId: input.channelAccountId, observation: { @@ -23,7 +22,6 @@ export const createOneTalkRenderedCardService = ( content, contentFingerprint: createOneTalkRenderedCardContentFingerprint(content), }, - receivedAt: now(), commitGuard: input.commitGuard, }); }, diff --git a/apps/server/test/onetalk-business-card-marker-migration.test.ts b/apps/server/test/onetalk-business-card-marker-migration.test.ts index 013d9c0..f9c1a6c 100644 --- a/apps/server/test/onetalk-business-card-marker-migration.test.ts +++ b/apps/server/test/onetalk-business-card-marker-migration.test.ts @@ -1,13 +1,22 @@ // 验证 OneTalk 名片资料从消息 JSONB 中移除的迁移边界 import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { migrationDirectory } from "../src/database/migration-config.ts"; -const migrationPath = resolve(process.cwd(), migrationDirectory, "0011_mushy_baron_strucker.sql"); +const migrationPathUrl = new URL( + `../${migrationDirectory}/0011_mushy_baron_strucker.sql`, + import.meta.url, +); +const migrationPath = fileURLToPath( + existsSync(migrationPathUrl) + ? migrationPathUrl + : new URL(`../../${migrationDirectory}/0011_mushy_baron_strucker.sql`, import.meta.url), +); test("normalizes every persisted business-card payload before enforcing the marker-only check", async () => { const sql = await readFile(migrationPath, "utf8"); diff --git a/apps/server/test/onetalk-image-dimensions-migration.test.ts b/apps/server/test/onetalk-image-dimensions-migration.test.ts index a7982f7..cbbd493 100644 --- a/apps/server/test/onetalk-image-dimensions-migration.test.ts +++ b/apps/server/test/onetalk-image-dimensions-migration.test.ts @@ -1,16 +1,21 @@ // 验证 OneTalk 图片宽高数据迁移的结构边界 import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { migrationDirectory } from "../src/database/migration-config.ts"; -const migrationPath = resolve( - process.cwd(), - migrationDirectory, - "0009_remove_image_dimensions.sql", +const migrationPathUrl = new URL( + `../${migrationDirectory}/0009_remove_image_dimensions.sql`, + import.meta.url, +); +const migrationPath = fileURLToPath( + existsSync(migrationPathUrl) + ? migrationPathUrl + : new URL(`../../${migrationDirectory}/0009_remove_image_dimensions.sql`, import.meta.url), ); test("keeps the image-dimensions migration as one breakpoint-delimited data statement", async () => { diff --git a/apps/server/test/onetalk-product-content-migration.test.ts b/apps/server/test/onetalk-product-content-migration.test.ts index f3bda1e..c058054 100644 --- a/apps/server/test/onetalk-product-content-migration.test.ts +++ b/apps/server/test/onetalk-product-content-migration.test.ts @@ -1,13 +1,22 @@ // 验证 OneTalk 商品内容只扩展 JSONB kind 约束,不重写既有事实。 import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import test from "node:test"; import { migrationDirectory } from "../src/database/migration-config.ts"; -const migrationPath = resolve(process.cwd(), migrationDirectory, "0013_nifty_thunderbird.sql"); +const migrationPathUrl = new URL( + `../${migrationDirectory}/0013_nifty_thunderbird.sql`, + import.meta.url, +); +const migrationPath = fileURLToPath( + existsSync(migrationPathUrl) + ? migrationPathUrl + : new URL(`../../${migrationDirectory}/0013_nifty_thunderbird.sql`, import.meta.url), +); test("extends only the content-kind check with product while preserving existing constraints", async () => { const sql = await readFile(migrationPath, "utf8"); diff --git a/apps/server/test/onetalk-read-domain.test.ts b/apps/server/test/onetalk-read-domain.test.ts index 040b572..99af051 100644 --- a/apps/server/test/onetalk-read-domain.test.ts +++ b/apps/server/test/onetalk-read-domain.test.ts @@ -666,6 +666,19 @@ test("projects inquiry, product, and order supplements without changing their ba } }); +test("attaches a generic DOM card beside an unchanged base message", () => { + const renderedCard = { + version: 1 as const, + status: "Paid", + total: "US $10.00", + image: "https://img.alicdn.com/card.jpg", + }; + const result = toOneTalkCenterMessage(message(), renderedCard); + + assert.deepEqual(result.content, message().content); + assert.deepEqual(result.renderedCard, renderedCard); +}); + test("rejects a rendered-card supplement with a different base kind or product identity", () => { const product = { version: 1 as const, diff --git a/apps/server/test/onetalk-rendered-card-flow.test.ts b/apps/server/test/onetalk-rendered-card-flow.test.ts index 06cce6a..9439632 100644 --- a/apps/server/test/onetalk-rendered-card-flow.test.ts +++ b/apps/server/test/onetalk-rendered-card-flow.test.ts @@ -20,14 +20,9 @@ const context = { }; const content = { version: 1 as const, - kind: "rendered_order" as const, - 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 }, + status: "Paid", + total: "US $10.00", + image: "https://img.alicdn.com/card.jpg", }; const frame = createOneTalkRenderedCardObservedFrame( { connectionType: "plugin", requestId: "request-1", scope }, @@ -171,16 +166,8 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) { participantIds: message.participantIds, direction: message.direction, sentAtMs: message.sentAtMs, - content: { - ...message.content, - title: content.title, - products: content.products, - productCount: content.productCount, - status: content.status, - payment: content.payment, - delivery: content.delivery, - action: content.action, - }, + content: message.content, + renderedCard: content, }, requestId: "request-1", scope: { diff --git a/apps/server/test/onetalk-rendered-card-migration.test.ts b/apps/server/test/onetalk-rendered-card-migration.test.ts index 4cf4d39..7c3dd38 100644 --- a/apps/server/test/onetalk-rendered-card-migration.test.ts +++ b/apps/server/test/onetalk-rendered-card-migration.test.ts @@ -1,8 +1,8 @@ // 验证渲染卡片补全使用独立、带备注的追加迁移。 import assert from "node:assert/strict"; -import { readdir, readFile } from "node:fs/promises"; -import { resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { readFile } from "node:fs/promises"; import test from "node:test"; import { ONETALK_INITIAL_HISTORY_GENERATION } from "@trade-message-center/onetalk-contract"; @@ -14,35 +14,32 @@ test("keeps the CJS-safe schema history default aligned with the shared contract assert.equal(ONETALK_SCHEMA_INITIAL_HISTORY_GENERATION, ONETALK_INITIAL_HISTORY_GENERATION); }); -test("adds the rendered-card fact table in the latest additive migration", async () => { - const directory = resolve(process.cwd(), migrationDirectory); - const files = (await readdir(directory)).filter((file) => /^\d+_.+\.sql$/u.test(file)).sort(); - const latest = files.at(-1); - assert.ok(latest, "expected at least one SQL migration"); - const sql = await readFile(resolve(directory, latest), "utf8"); - - assert.match(sql, /CREATE TABLE "onetalk_rendered_card_content"/i); - assert.match( - sql, - /PRIMARY KEY\s*\("channel_account_id",\s*"conversation_id",\s*"message_id"\)/i, +test("drops obsolete audit columns and expands the rendered-card kinds without rewriting history", async () => { + const sourceMigrationUrl = new URL(`../${migrationDirectory}/`, import.meta.url); + const migrationUrl = existsSync(sourceMigrationUrl) + ? sourceMigrationUrl + : new URL(`../../${migrationDirectory}/`, import.meta.url); + const migration015 = await readFile(new URL("0015_wild_toad_men.sql", migrationUrl), "utf8"); + const migration016 = await readFile( + new URL("0016_optimal_imperial_guard.sql", migrationUrl), + "utf8", ); - assert.match(sql, /COMMENT ON TABLE "onetalk_rendered_card_content"/i); + for (const column of [ - "channel_account_id", - "conversation_id", - "message_id", - "rendered_card_content", - "rendered_card_content_fingerprint", - "rendered_card_observed_at_ms", "first_confirmed_at", "last_observed_at", - "conflict_count", "last_conflicting_fingerprint", "last_conflict_observed_at_ms", - ]) { + ]) assert.match( - sql, - new RegExp(`COMMENT ON COLUMN "onetalk_rendered_card_content"."${column}"`, "i"), + migration015, + new RegExp(`ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "${column}"`, "i"), ); - } + assert.doesNotMatch(migration015, /\b(?:DELETE|TRUNCATE|CREATE\s+TABLE|CONSTRAINT)\b/i); + assert.doesNotMatch(migration016, /\b(?:DELETE|TRUNCATE|CREATE\s+TABLE|DROP\s+COLUMN)\b/i); + assert.match(migration016, /DROP CONSTRAINT "onetalk_rendered_card_content_v1_chk"/i); + assert.match( + migration016, + /ADD CONSTRAINT "onetalk_rendered_card_content_v1_chk" CHECK .*not .*\? 'kind'/is, + ); }); diff --git a/apps/server/test/onetalk-rendered-card-repository.test.ts b/apps/server/test/onetalk-rendered-card-repository.test.ts index 148fc52..03756ce 100644 --- a/apps/server/test/onetalk-rendered-card-repository.test.ts +++ b/apps/server/test/onetalk-rendered-card-repository.test.ts @@ -8,14 +8,9 @@ import { createOneTalkRenderedCardRepository } from "../src/onetalk/rendered-car const content = { version: 1 as const, - kind: "rendered_order" as const, - title: "Order", - products: [], - productCount: 0, - status: { code: null, text: "Paid" }, - payment: { totalDisplay: "$1", discountDisplay: null }, - delivery: { shippingAddress: "Hangzhou", methodLabel: null, dateLabel: null }, - action: { label: null, status: null }, + status: "Paid", + total: "US $10.00", + image: "https://img.alicdn.com/card.jpg", }; test("stores a rendered-card supplement when its base message is absent", async () => { @@ -52,7 +47,6 @@ test("stores a rendered-card supplement when its base message is absent", async contentFingerprint: createOneTalkRenderedCardContentFingerprint(content), observedAtMs: 100, }, - receivedAt: new Date("2026-09-15T00:00:00.000Z"), }); assert.deepEqual(result, { status: "accepted", renderedCardContent: content, message: null }); @@ -64,8 +58,60 @@ test("stores a rendered-card supplement when its base message is absent", async renderedCardContent: content, renderedCardContentFingerprint: createOneTalkRenderedCardContentFingerprint(content), renderedCardObservedAtMs: 100, - firstConfirmedAt: new Date("2026-09-15T00:00:00.000Z"), - lastObservedAt: new Date("2026-09-15T00:00:00.000Z"), }, ]); }); + +test("keeps the first supplement snapshot and only increments conflictCount for different content", async () => { + const updates: unknown[] = []; + const conflictingContent = { ...content, status: "Cancelled" }; + const selectResults = [ + [ + { + channelAccountId: "account-1", + conversationId: "conversation-1", + messageId: "message-1", + renderedCardContent: content, + }, + ], + [], + ]; + const transaction = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => { + const result = selectResults.shift(); + return Object.assign(Promise.resolve(result), { for: () => result }); + }, + }), + }), + }), + insert: () => assert.fail("unexpected insert"), + update: () => ({ + set: (value: unknown) => ({ + where: async () => updates.push(value), + }), + }), + }; + const database = { + transaction: async (run: (value: typeof transaction) => Promise) => + run(transaction), + }; + const repository = createOneTalkRenderedCardRepository(database as never); + + const result = await repository.store({ + channelAccountId: "account-1", + observation: { + conversationId: "conversation-1", + messageId: "message-1", + content: conflictingContent, + contentFingerprint: createOneTalkRenderedCardContentFingerprint(conflictingContent), + observedAtMs: 101, + }, + }); + + assert.deepEqual(result, { status: "conflict", renderedCardContent: content, message: null }); + assert.equal(updates.length, 1); + assert.deepEqual(Object.keys(updates[0] as object), ["conflictCount"]); +}); diff --git a/apps/server/test/onetalk-websocket.test.ts b/apps/server/test/onetalk-websocket.test.ts index 1ab7355..6cb9e06 100644 --- a/apps/server/test/onetalk-websocket.test.ts +++ b/apps/server/test/onetalk-websocket.test.ts @@ -837,14 +837,10 @@ test("executes a wire assertion for every declared OneTalk client frame", async const renderedCardContent = { version: 1 as const, - kind: "rendered_order" as const, - title: "Matrix order", - products: [], - productCount: 0, - status: { code: null, text: "Paid" }, - payment: { totalDisplay: "$1", discountDisplay: null }, - delivery: { shippingAddress: "Matrix address", methodLabel: null, dateLabel: null }, - action: { label: null, status: null }, + status: "Paid", + total: "$1", + shippingAddress: "Matrix address", + productCount: { display: "0 items", value: 0 }, }; const renderedCardAck = nextMessage(plugin); const renderedCardUpdate = nextFrame(mind, (frame) => frame.type === "message.updated"); @@ -880,32 +876,7 @@ test("executes a wire assertion for every declared OneTalk client frame", async assert.deepEqual(renderedCardFrames[1]?.payload, { message: { ...centerMessage(orderMessage("matrix-rendered-card-message")), - content: { - version: 1, - kind: "order", - orderId: "order-1", - bizCode: null, - contractId: null, - id: null, - tenant: null, - orderAmount: 1, - orderAmountCurrency: "USD", - paymentAmount: 1, - paymentAmountCurrency: "USD", - statusMessageKey: "paid", - actions: [], - title: "Matrix order", - products: [], - productCount: 0, - status: { code: null, text: "Paid" }, - payment: { totalDisplay: "$1", discountDisplay: null }, - delivery: { - shippingAddress: "Matrix address", - methodLabel: null, - dateLabel: null, - }, - action: { label: null, status: null }, - }, + renderedCard: renderedCardContent, }, }); assert.equal(renderedCardInputs.length, 1); diff --git a/docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md b/docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md new file mode 100644 index 0000000..c79fa7c --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md @@ -0,0 +1,186 @@ +# OneTalk DOM Secondary Card Collection Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the Fiber-backed secondary card producer with strict DOM readers for inquiry, product, and order cards, while removing four obsolete server audit columns. + +**Architecture:** A single DOM identity resolver validates card and wrapper metadata before three typed readers produce the existing rendered-card contract. The bridge and local ledger cease carrying unused Fiber-only base evidence; the server wire and JSON content shapes remain unchanged. The migration drops only approved columns and preserves first-content-wins with `conflict_count`. + +**Tech Stack:** TypeScript, MV3 MAIN/ISOLATED page bridge, IndexedDB, shared OneTalk contract, Fastify/Drizzle/PostgreSQL, Node test runner. + +**Spec:** `.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/{prd,design,implement}.md` + +## Global Constraints + +- DOM is a structural data source; no React/Fiber inspection, root traversal, timer rescan, raw metadata forwarding, or fallback path. +- `messageId` must be present and match in card metadata and closest wrapper metadata whenever both exist; metadata conversation IDs must also agree when both exist. +- Quote (`cardType=8`) and unknown cards fail closed. +- Keep existing three rendered content JSON shapes, server frame, compound message identity, fingerprint, observedAtMs and `conflict_count`. +- Migration never issues DELETE, TRUNCATE, table rebuild or historical JSON rewrite. + +--- + +### Task 1: DOM reader and legacy reader removal + +**Files:** +- Create: `apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader.ts` +- Delete: `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` +- Test: `apps/chrome-extension/test/onetalk-rendered-card-reader.test.js` + +**Interfaces:** +- Produces: `readOneTalkRenderedCardFromDom(pageWindow, card): OneTalkRenderedCardObservation | null`. +- Consumes: `readConversationSelection`, `normalizeOneTalkProductUrl`, rendered-card validators and fingerprint creator. + +- [ ] **Step 1: Replace Fiber fixtures with DOM fixtures and red tests.** + +```js +assert.equal(readOneTalkRenderedCardFromDom(page, cardWith({ cardMessageId: "m-1", wrapperMessageId: "m-2" })), null); +assert.equal(readOneTalkRenderedCardFromDom(page, quoteCard({ cardType: 8 })), null); +assert.equal(readOneTalkRenderedCardFromDom(page, productCard({ rawUrl: "https://bad.example" })), null); +``` + +- [ ] **Step 2: Run the focused reader test and confirm the missing DOM reader fails.** + +```text +node --experimental-strip-types --test apps/chrome-extension/test/onetalk-rendered-card-reader.test.js +``` + +- [ ] **Step 3: Implement the shared resolver and three projections.** + +```ts +const messageId = cardMessageId ?? wrapperMessageId; +if (!messageId || (cardMessageId && wrapperMessageId && cardMessageId !== wrapperMessageId)) return null; +if (!sameOptionalConversation(cardInfo, wrapperInfo)) return null; +``` + +Construct only exact `rendered_inquiry`, `rendered_product`, or `rendered_order` shapes, validate with `isOneTalkRenderedCardContent`, and return an observation whose fingerprint is recomputed from that content. + +- [ ] **Step 4: Rewrite entry to scan cards and react only to DOM mutations.** + +```ts +for (const card of cardsForMutation(record)) publish(readOneTalkRenderedCardFromDom(pageWindow, card)); +observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-expinfo", "src", "style"] }); +``` + +Remove `setInterval`, wrapper rescans, React imports, and the deleted reader file. + +- [ ] **Step 5: Run focused reader/observer tests.** + +```text +node --experimental-strip-types --test apps/chrome-extension/test/onetalk-rendered-card-reader.test.js apps/chrome-extension/test/onetalk-rendered-card-observer.test.js +``` + +### Task 2: Remove Fiber-only base evidence from local boundaries + +**Files:** +- Modify: `apps/chrome-extension/src/onetalk/page-bridge/{main.ts,model.ts}` +- Modify: `apps/chrome-extension/src/onetalk/service-worker/{rendered-card-coordinator.ts,storage.ts}` +- Test: `apps/chrome-extension/test/{onetalk-page-bridge,onetalk-rendered-card-coordinator,onetalk-sync-storage}.test.js` + +**Interfaces:** +- `onetalk.page.rendered-card-observed` contains `{ channelAccountId, observations }` only. +- `OneTalkRenderedCardCoordinator.observe(observations)` persists and flushes observations without base evidence. + +- [ ] **Step 1: Add bridge and ledger tests that accept an observation-only message and reject legacy base-evidence fields.** + +```js +assert.deepEqual(decodeOneTalkPageMessage({ source, version, type, channelAccountId, observations }), expected); +assert.equal("baseDirection" in pendingRecord, false); +``` + +- [ ] **Step 2: Run focused tests and confirm they fail against the old exact shape.** + +```text +node --experimental-strip-types --test apps/chrome-extension/test/onetalk-page-bridge.test.js apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js apps/chrome-extension/test/onetalk-sync-storage.test.js +``` + +- [ ] **Step 3: Remove baseEvidence from the page message decoder/sink and from coordinator/ledger APIs.** + +```ts +return coordinator.observe(message.observations); +await ledger.observe({ channelAccountId: scope.channelAccountId, observation }); +``` + +Keep `markSent`, `listPending`, `markAcknowledged`, fingerprints and exact ACK matching unchanged. + +- [ ] **Step 4: Re-run focused tests.** + +```text +node --experimental-strip-types --test apps/chrome-extension/test/onetalk-page-bridge.test.js apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js apps/chrome-extension/test/onetalk-sync-storage.test.js +``` + +### Task 3: Remove approved server audit columns without data cleanup + +**Files:** +- Modify: `apps/server/src/database/schema/onetalk.ts` +- Modify: `apps/server/src/onetalk/rendered-card-{model,repository,service}.ts` +- Create: generated `apps/server/drizzle/0015_*.sql` and matching `drizzle/meta` files +- Test: `apps/server/test/onetalk-rendered-card-{migration,repository,flow}.test.ts` + +**Interfaces:** +- Stored row retains key, `renderedCardContent`, `renderedCardContentFingerprint`, `renderedCardObservedAtMs`, and `conflictCount`. +- Same content stays duplicate; different content increments `conflictCount` and returns conflict without overwriting JSON. + +- [ ] **Step 1: Change migration/repository test expectations to the retained row shape and conflict-only audit.** + +```ts +assert.equal(saved.conflictCount, 1); +assert.equal("lastObservedAt" in saved, false); +assert.doesNotMatch(migrationSql, /\b(?:DELETE|TRUNCATE)\b/i); +``` + +- [ ] **Step 2: Run the server rendered-card tests and confirm failure.** + +```text +node --experimental-strip-types --test apps/server/test/onetalk-rendered-card-migration.test.ts apps/server/test/onetalk-rendered-card-repository.test.ts apps/server/test/onetalk-rendered-card-flow.test.ts +``` + +- [ ] **Step 3: Remove four Drizzle fields and generated SQL columns, then remove their repository/service writes.** + +```sql +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "first_confirmed_at"; +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_observed_at"; +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_conflicting_fingerprint"; +ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_conflict_observed_at_ms"; +``` + +Retain `conflict_count = conflict_count + 1` in the conflict branch and leave all supplement JSON untouched. + +- [ ] **Step 4: Run migration/repository/flow tests and Drizzle validation.** + +```text +node --experimental-strip-types --test apps/server/test/onetalk-rendered-card-migration.test.ts apps/server/test/onetalk-rendered-card-repository.test.ts apps/server/test/onetalk-rendered-card-flow.test.ts +pnpm --filter @trade-message-center/server db:check +``` + +### Task 4: Contract-preserving integration verification + +**Files:** +- Test: existing contract, read-projection, extension and server suites + +- [ ] **Step 1: Add read projection regressions for all three base kinds and reject mismatched supplement kinds.** + +```ts +assert.equal(projected.content.kind, "inquiry"); +assert.throws(() => toOneTalkCenterMessage(product, renderedInquiry)); +``` + +- [ ] **Step 2: Run all scoped checks.** + +```text +pnpm --filter @trade-message-center/onetalk-contract test +pnpm --filter @trade-message-center/chrome-extension test +pnpm --filter @trade-message-center/chrome-extension typecheck +pnpm --filter @trade-message-center/chrome-extension build +pnpm --filter @trade-message-center/server typecheck +git diff --check +``` + +- [ ] **Step 3: Run final source-boundary searches.** + +```text +rg -n 'React|Fiber|baseEvidence|baseDirection|baseSentAtMs|setInterval' apps/chrome-extension/src/onetalk/main-page/card-observer apps/chrome-extension/src/onetalk/page-bridge apps/chrome-extension/src/onetalk/service-worker +``` + +Expected: no legacy secondary-card collector references; unrelated modules may remain outside the reviewed paths. diff --git a/packages/onetalk-contract/src/content.ts b/packages/onetalk-contract/src/content.ts index 3802d85..e55ec9d 100644 --- a/packages/onetalk-contract/src/content.ts +++ b/packages/onetalk-contract/src/content.ts @@ -14,6 +14,7 @@ import { isOneTalkRenderedCardPayment, isOneTalkRenderedCardQuantity, isOneTalkRenderedCardText, + isOneTalkRenderedCard, type OneTalkRenderedCardAction, type OneTalkRenderedCardDelivery, type OneTalkRenderedCardImage, @@ -21,6 +22,7 @@ import { type OneTalkRenderedCardOrderStatus, type OneTalkRenderedCardPayment, type OneTalkRenderedCardQuantity, + type OneTalkRenderedCard, } from "./rendered-cards.ts"; export const ONETALK_CONTENT_VERSION = 1 as const; @@ -188,6 +190,8 @@ export type OneTalkCenterMessage = { direction: OneTalkDirection; sentAtMs: number; content: OneTalkCenterMessageContent; + /** 与基础消息同一复合 identity 的 DOM-only 公共快照。 */ + renderedCard?: OneTalkRenderedCard; }; export type OneTalkMessageContentDecodeResult = @@ -262,6 +266,8 @@ const CENTER_MESSAGE_KEYS = [ "content", ] as const; +const CENTER_MESSAGE_RENDERED_CARD_KEYS = [...CENTER_MESSAGE_KEYS, "renderedCard"] as const; + const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm"; const THUMBNAIL_URL_PATH = "/file/videoThumb.htm"; const ALLOWED_MEDIA_QUERY_KEYS = [ @@ -712,7 +718,12 @@ export const isCenterConversation = (value: unknown): value is CenterConversatio /** 判断值是否为 exact-shape 的 Mind 公开消息读取模型。 */ export const isOneTalkCenterMessage = (value: unknown): value is OneTalkCenterMessage => { - if (!isPlainRecord(value) || !hasExactKeys(value, CENTER_MESSAGE_KEYS)) return false; + if ( + !isPlainRecord(value) || + (!hasExactKeys(value, CENTER_MESSAGE_KEYS) && + !hasExactKeys(value, CENTER_MESSAGE_RENDERED_CARD_KEYS)) + ) + return false; return ( isNonBlankString(value.messageId, MAX_IDENTIFIER_LENGTH) && isNonBlankString(value.conversationId, MAX_IDENTIFIER_LENGTH) && @@ -721,6 +732,7 @@ export const isOneTalkCenterMessage = (value: unknown): value is OneTalkCenterMe typeof value.direction === "string" && ONETALK_DIRECTIONS.includes(value.direction as OneTalkDirection) && isNonNegativeSafeInteger(value.sentAtMs) && - isOneTalkCenterMessageContent(value.content) + isOneTalkCenterMessageContent(value.content) && + (value.renderedCard === undefined || isOneTalkRenderedCard(value.renderedCard)) ); }; diff --git a/packages/onetalk-contract/src/index.ts b/packages/onetalk-contract/src/index.ts index 13608d1..4a966da 100644 --- a/packages/onetalk-contract/src/index.ts +++ b/packages/onetalk-contract/src/index.ts @@ -132,10 +132,12 @@ export { ONETALK_RENDERED_CARD_CONTENT_VERSION, ONETALK_RENDERED_CARD_ACK_STATUSES, cloneOneTalkRenderedCardContent, + cloneOneTalkRenderedCard, createOneTalkRenderedCardAckFrame, createOneTalkRenderedCardContentFingerprint, createOneTalkRenderedCardObservedFrame, isOneTalkRenderedCardContent, + isOneTalkRenderedCard, isOneTalkRenderedCardImage, isOneTalkRenderedCardImageUrl, isOneTalkRenderedCardIdentifier, @@ -154,6 +156,7 @@ export type { OneTalkRenderedCardAction, OneTalkRenderedCardAckFrame, OneTalkRenderedCardContent, + OneTalkRenderedCard, OneTalkRenderedCardImage, OneTalkRenderedCardOrderStatus, OneTalkRenderedCardPayment, diff --git a/packages/onetalk-contract/src/rendered-cards.ts b/packages/onetalk-contract/src/rendered-cards.ts index e26e25a..206f8e2 100644 --- a/packages/onetalk-contract/src/rendered-cards.ts +++ b/packages/onetalk-contract/src/rendered-cards.ts @@ -54,15 +54,31 @@ export type OneTalkRenderedOrderContent = { delivery: OneTalkRenderedCardDelivery; action: OneTalkRenderedCardOrderAction; }; +/** 仅表达当前已渲染可见内容的类型无关命名卡片快照。 */ +export type OneTalkRenderedCard = { + version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION; + title?: string; + image?: string; + price?: string; + minimumOrder?: string; + purchaseQuantity?: string; + requirementText?: string; + productCount?: { display: string; value: number | null }; + status?: string; + total?: string; + deliveryDate?: string; + shippingAddress?: string; +}; export type OneTalkRenderedCardContent = | OneTalkRenderedInquiryContent | OneTalkRenderedProductContent - | OneTalkRenderedOrderContent; + | OneTalkRenderedOrderContent + | OneTalkRenderedCard; export type OneTalkRenderedCardObservation = { conversationId: string; messageId: string; - content: OneTalkRenderedCardContent; + content: OneTalkRenderedCard; contentFingerprint: string; observedAtMs: number; }; @@ -220,17 +236,77 @@ export const isSameOneTalkRenderedCardContent = ( right: OneTalkRenderedCardContent, ): boolean => canonicalize(left) === canonicalize(right); -/** 克隆跨世界传输的受限卡片快照,拒绝保留 Fiber 或 raw props 引用。 */ +/** 克隆跨世界传输的受限卡片快照,拒绝保留框架对象或 raw props 引用。 */ export const cloneOneTalkRenderedCardContent = ( content: OneTalkRenderedCardContent, ): OneTalkRenderedCardContent => JSON.parse(canonicalize(content)) as OneTalkRenderedCardContent; +/** 克隆仅允许新 observation 写入的无 kind 命名快照。 */ +export const cloneOneTalkRenderedCard = (content: OneTalkRenderedCard): OneTalkRenderedCard => + JSON.parse(canonicalize(content)) as OneTalkRenderedCard; + +const isBoundedNonEmptyArray = ( + value: unknown, + predicate: (item: unknown) => item is T, +): value is T[] => + Array.isArray(value) && value.length > 0 && value.length <= MAX_ITEMS && value.every(predicate); + +const isProductCount = ( + value: unknown, +): value is NonNullable => + isPlainRecord(value) && + hasExactKeys(value, ["display", "value"]) && + isOneTalkRenderedCardText(value.display) && + (value.value === null || isSafeNonNegativeInteger(value.value)); + +const RENDERED_CARD_KEYS = [ + "version", + "title", + "image", + "price", + "minimumOrder", + "purchaseQuantity", + "requirementText", + "productCount", + "status", + "total", + "deliveryDate", + "shippingAddress", +] as const; + +/** 验证无 kind、无 DOM 元数据的 type-agnostic 命名公开形状。 */ +export const isOneTalkRenderedCard = (value: unknown): value is OneTalkRenderedCard => { + if ( + !isPlainRecord(value) || + value.version !== ONETALK_RENDERED_CARD_CONTENT_VERSION || + Object.keys(value).some((key) => !RENDERED_CARD_KEYS.includes(key as never)) + ) + return false; + const namedFieldCount = Object.keys(value).length - 1; + return ( + namedFieldCount > 0 && + (value.title === undefined || isOneTalkRenderedCardText(value.title)) && + (value.image === undefined || isOneTalkRenderedCardImageUrl(value.image)) && + (value.price === undefined || isOneTalkRenderedCardText(value.price)) && + (value.minimumOrder === undefined || isOneTalkRenderedCardText(value.minimumOrder)) && + (value.purchaseQuantity === undefined || + isOneTalkRenderedCardText(value.purchaseQuantity)) && + (value.requirementText === undefined || isOneTalkRenderedCardText(value.requirementText)) && + (value.productCount === undefined || isProductCount(value.productCount)) && + (value.status === undefined || isOneTalkRenderedCardText(value.status)) && + (value.total === undefined || isOneTalkRenderedCardText(value.total)) && + (value.deliveryDate === undefined || isOneTalkRenderedCardText(value.deliveryDate)) && + (value.shippingAddress === undefined || isOneTalkRenderedCardText(value.shippingAddress)) + ); +}; + /** 解码精确 shape 的受控渲染卡片内容。 */ export const isOneTalkRenderedCardContent = ( value: unknown, ): value is OneTalkRenderedCardContent => { if (!isPlainRecord(value) || value.version !== ONETALK_RENDERED_CARD_CONTENT_VERSION) return false; + if (!("kind" in value)) return isOneTalkRenderedCard(value); if (value.kind === "rendered_inquiry") { return ( hasExactKeys(value, [ @@ -319,7 +395,7 @@ const isObservation = (value: unknown): value is OneTalkRenderedCardObservation ]) && isOneTalkRenderedCardIdentifier(value.conversationId) && isOneTalkRenderedCardIdentifier(value.messageId) && - isOneTalkRenderedCardContent(value.content) && + isOneTalkRenderedCard(value.content) && isOneTalkRenderedCardIdentifier(value.contentFingerprint) && isSafeNonNegativeInteger(value.observedAtMs) && value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content); @@ -361,7 +437,7 @@ export const createOneTalkRenderedCardObservedFrame = ( type: "rendered.card.observed", requestId: frame.requestId, scope: frame.scope, - payload: { ...observation, content: cloneOneTalkRenderedCardContent(observation.content) }, + payload: { ...observation, content: cloneOneTalkRenderedCard(observation.content) }, }); /** 创建对应精确 observation 的服务端 ACK。 */ diff --git a/packages/onetalk-contract/test/contract.test.ts b/packages/onetalk-contract/test/contract.test.ts index 8f452f3..919b362 100644 --- a/packages/onetalk-contract/test/contract.test.ts +++ b/packages/onetalk-contract/test/contract.test.ts @@ -133,14 +133,9 @@ const textContent = { test("strictly validates rendered-card frames, canonical fingerprints, and in-place updates", () => { const content = { version: 1 as const, - kind: "rendered_order" as const, - title: "Order summary", - products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }], - productCount: 1, - status: { code: "paid", text: "Paid" }, - payment: { totalDisplay: "US $10.00", discountDisplay: null }, - delivery: { shippingAddress: "1 Market Street", methodLabel: null, dateLabel: null }, - action: { label: "View order", status: "available" }, + status: "Paid", + total: "US $10.00", + image: "https://img.alicdn.com/item.jpg", }; assert.equal(isOneTalkRenderedCardContent(content), true); const fingerprint = createOneTalkRenderedCardContentFingerprint(content); @@ -154,8 +149,10 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl assert.deepEqual(decodeOneTalkFrame(frame), { ok: true, frame }); assert.equal( isSameOneTalkRenderedCardContent(content, { - ...content, - status: { text: "Paid", code: "paid" }, + image: "https://img.alicdn.com/item.jpg", + total: "US $10.00", + status: "Paid", + version: 1, }), true, ); @@ -171,15 +168,7 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl ...frame, payload: { ...frame.payload, - content: { - ...content, - products: [ - { - ...content.products[0], - imageUrl: "https://img.alicdn.com/item.jpg?token=x", - }, - ], - }, + content: { ...content, image: "https://img.alicdn.com/item.jpg?token=x" }, }, }), { ok: false, code: ONETALK_ERROR_CODES.invalidMessage }, @@ -207,19 +196,96 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl paymentAmountCurrency: "USD", statusMessageKey: "paid", actions: [], - title: content.title, - products: content.products, - productCount: content.productCount, - status: content.status, - payment: content.payment, - delivery: content.delivery, - action: content.action, }, }, ); assert.deepEqual(decodeOneTalkFrame(updated), { ok: true, frame: updated }); }); +test("rejects legacy typed rendered-card content on the observation wire", () => { + const legacyContent = { + version: 1 as const, + kind: "rendered_order" as const, + title: "Order summary", + products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }], + productCount: 1, + status: { code: "paid", text: "Paid" }, + payment: { totalDisplay: "US $10.00", discountDisplay: null }, + delivery: { shippingAddress: "1 Market Street", methodLabel: null, dateLabel: null }, + action: { label: "View order", status: "available" }, + }; + const frame = createOneTalkRenderedCardObservedFrame(frameBase, { + conversationId: "conversation-1", + messageId: "message-1", + content: legacyContent as never, + contentFingerprint: createOneTalkRenderedCardContentFingerprint(legacyContent), + observedAtMs: 1_700_000_000_001, + }); + assert.deepEqual(decodeOneTalkFrame(frame), { + ok: false, + code: ONETALK_ERROR_CODES.invalidMessage, + }); +}); + +test("accepts only an exact named rendered card without a business kind on a CenterMessage", () => { + const renderedCard = { + version: 1 as const, + title: "Order #1", + total: "US $10.00", + image: "https://img.alicdn.com/card.jpg", + }; + assert.equal(isOneTalkRenderedCardContent(renderedCard), true); + assert.equal(isOneTalkCenterMessage({ ...centerMessage, renderedCard }), true); + assert.equal( + isOneTalkCenterMessage({ + ...centerMessage, + renderedCard: { ...renderedCard, kind: "order" }, + }), + false, + ); + assert.equal(isOneTalkRenderedCardContent({ version: 1, texts: ["unstructured"] }), false); +}); + +test("accepts scalar rendered-card fields and rejects superseded collection shapes", () => { + assert.equal( + isOneTalkRenderedCardContent({ + version: 1, + image: "https://img.alicdn.com/card.jpg", + purchaseQuantity: "500 Piece/Pieces", + minimumOrder: "10 Pieces", + }), + true, + ); + for (const renderedCard of [ + { + version: 1, + requirementText: "Need custom sizing", + reference: "13894257698", + }, + { + version: 1, + images: ["https://img.alicdn.com/card.jpg"], + }, + { + version: 1, + productImages: ["https://img.alicdn.com/card.jpg"], + }, + { + version: 1, + promotionTexts: ["Super"], + }, + { + version: 1, + attributes: [{ label: "采购数量", value: "500 Piece/Pieces" }], + }, + { + version: 1, + minimumOrder: { value: "10", unit: "Pieces" }, + }, + ]) + assert.equal(isOneTalkRenderedCardContent(renderedCard), false); +}); + const jpegContent = { version: ONETALK_CONTENT_VERSION, kind: "image" as const,