mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: add React commit collection for rendered cards
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
|
||||
@@ -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."}
|
||||
@@ -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。
|
||||
@@ -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."}
|
||||
@@ -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
|
||||
```
|
||||
@@ -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 由用户在任务外自行清理;本任务不执行历史数据操作。
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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<string, unknown> | 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<string, unknown>): 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(),
|
||||
};
|
||||
};
|
||||
@@ -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<CSSStyleDeclaration, "display" | "visibility">;
|
||||
}
|
||||
).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<string> };
|
||||
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<string>,
|
||||
): 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<string>();
|
||||
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<Element> = 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<OneTalkRenderedCard, "title" | "price" | "minimumOrder" | "image"> & {
|
||||
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 } : {}),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<OneTalkRenderedCard, "title" | "image"> => {
|
||||
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<Element>([...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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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<OneTalkPageWindow["document"]>,
|
||||
): 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<Element>();
|
||||
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<string>();
|
||||
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<Element>();
|
||||
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();
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
};
|
||||
export type OneTalkRenderedCardRead = {
|
||||
observation: OneTalkRenderedCardObservation;
|
||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence;
|
||||
};
|
||||
type CardClassification =
|
||||
| { kind: "order"; data: Record<string, unknown> }
|
||||
| { kind: "inquiry"; data: Record<string, unknown> }
|
||||
| { kind: "product"; data: Record<string, unknown> };
|
||||
|
||||
const MAX_FIBER_ANCESTORS = 60;
|
||||
const MAX_FIBER_DESCENDANTS = 120;
|
||||
|
||||
const text = (value: unknown): string | null => (isOneTalkRenderedCardText(value) ? value : null);
|
||||
const nullableText = (value: unknown): string | null => (value === null ? null : text(value));
|
||||
const imageUrl = (value: unknown): string | null =>
|
||||
isOneTalkRenderedCardImageUrl(value) ? value : null;
|
||||
const scalarId = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim().length > 0
|
||||
? value
|
||||
: typeof value === "number" && Number.isSafeInteger(value)
|
||||
? String(value)
|
||||
: null;
|
||||
const nonNegativeInteger = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
|
||||
|
||||
const fiberFor = (node: Node): FiberRecord | null => {
|
||||
for (const key of Object.keys(node)) {
|
||||
if (!key.startsWith("__reactFiber$")) continue;
|
||||
const fiber = (node as unknown as Record<string, unknown>)[key];
|
||||
if (isObjectRecord(fiber)) return fiber as FiberRecord;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fiberIn = (wrapper: Element): FiberRecord | null =>
|
||||
fiberFor(wrapper) ??
|
||||
Array.from(wrapper.querySelectorAll("*"))
|
||||
.map(fiberFor)
|
||||
.find((value): value is FiberRecord => value !== null) ??
|
||||
null;
|
||||
|
||||
const itemDataFor = (fiber: FiberRecord): ItemData | null => {
|
||||
let cursor: FiberRecord | null | undefined = fiber;
|
||||
for (let depth = 0; cursor && depth < MAX_FIBER_ANCESTORS; depth += 1) {
|
||||
const current: FiberRecord = cursor;
|
||||
cursor = current.return;
|
||||
const props = current.memoizedProps;
|
||||
if (!isObjectRecord(props) || !isObjectRecord(props.itemData)) continue;
|
||||
const item = props.itemData;
|
||||
const messageId = scalarId(item.messageId);
|
||||
const conversationCode = text(item.conversationCode);
|
||||
const messageType =
|
||||
item.messageType === "rec" || item.messageType === "send" ? item.messageType : null;
|
||||
const sendTime = nonNegativeInteger(item.sendTime);
|
||||
const msgType = nonNegativeInteger(item.msgType);
|
||||
if (
|
||||
!messageId ||
|
||||
!conversationCode ||
|
||||
!messageType ||
|
||||
sendTime === null ||
|
||||
msgType === null ||
|
||||
!isObjectRecord(item.originalData)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
conversationCode,
|
||||
messageType,
|
||||
sendTime,
|
||||
msgType,
|
||||
originalData: item.originalData,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const fibersFor = (fiber: FiberRecord): FiberRecord[] => {
|
||||
const pending: FiberRecord[] = [fiber];
|
||||
for (let index = 0; index < pending.length && index < MAX_FIBER_DESCENDANTS; index += 1) {
|
||||
const current = pending[index]!;
|
||||
if (current.child) pending.push(current.child);
|
||||
if (current !== fiber && current.sibling) pending.push(current.sibling);
|
||||
}
|
||||
return pending.slice(0, MAX_FIBER_DESCENDANTS);
|
||||
};
|
||||
|
||||
const templateDataFor = (
|
||||
fiber: FiberRecord,
|
||||
predicate: (data: Record<string, unknown>) => boolean,
|
||||
): Record<string, unknown> | null => {
|
||||
for (const current of fibersFor(fiber)) {
|
||||
const props = current.memoizedProps;
|
||||
if (!isObjectRecord(props) || !isObjectRecord(props.data) || !predicate(props.data))
|
||||
continue;
|
||||
return props.data;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const hasRendererCardType = (fiber: FiberRecord, cardType: number): boolean =>
|
||||
fibersFor(fiber).some((current) => {
|
||||
const props = current.memoizedProps;
|
||||
return isObjectRecord(props) && props.cardType === cardType;
|
||||
});
|
||||
|
||||
const productFor = (value: unknown): { imageUrl: string | null; title: string } | null => {
|
||||
if (!isObjectRecord(value)) return null;
|
||||
const title =
|
||||
text(value.productName) ??
|
||||
text(value.title) ??
|
||||
text(value.productTitle) ??
|
||||
text(value.name);
|
||||
if (!title) return null;
|
||||
const imageValues = [value.imageUrl, value.productImage, value.image].filter(
|
||||
(image): image is unknown => image !== undefined && image !== null,
|
||||
);
|
||||
if (imageValues.some((value) => imageUrl(value) === null)) return null;
|
||||
return {
|
||||
imageUrl:
|
||||
imageValues.map(imageUrl).find((value): value is string => value !== null) ?? null,
|
||||
title,
|
||||
};
|
||||
};
|
||||
|
||||
const orderTemplate = (data: Record<string, unknown>): boolean =>
|
||||
text(data.cardTitle) !== null &&
|
||||
Array.isArray(data.productInfoList ?? data.productList) &&
|
||||
text(data.orderStatusText) !== null &&
|
||||
text(data.shippingAddress) !== null;
|
||||
|
||||
const inquiryTemplate = (data: Record<string, unknown>): boolean =>
|
||||
isObjectRecord(data.inquiryCardDTO) &&
|
||||
Array.isArray(data.displayProducts ?? data.products) &&
|
||||
text(data.inquiryContent) !== null &&
|
||||
scalarId(data.inquiryID) !== null;
|
||||
|
||||
const productTemplate = (data: Record<string, unknown>): boolean => {
|
||||
const productAction = data.productAction;
|
||||
return (
|
||||
text(data.title) !== null &&
|
||||
imageUrl(data.productImage) !== null &&
|
||||
text(data.price) !== null &&
|
||||
text(data.moq) !== null &&
|
||||
text(data.moqUnit) !== null &&
|
||||
isObjectRecord(productAction) &&
|
||||
isObjectRecord(productAction.actionParams) &&
|
||||
normalizeOneTalkProductUrl(productAction.actionParams.url) !== null
|
||||
);
|
||||
};
|
||||
|
||||
const classifyRenderedCard = (item: ItemData, fiber: FiberRecord): CardClassification | null => {
|
||||
if (item.msgType === 10010 && item.originalData.cardType === 9) {
|
||||
const data = templateDataFor(fiber, orderTemplate);
|
||||
return data ? { kind: "order", data } : null;
|
||||
}
|
||||
if (item.msgType === 10010 && item.originalData.cardType === 6) {
|
||||
const data = templateDataFor(fiber, inquiryTemplate);
|
||||
return data ? { kind: "inquiry", data } : null;
|
||||
}
|
||||
if (item.msgType === 101 && hasRendererCardType(fiber, 54)) {
|
||||
const data = templateDataFor(fiber, productTemplate);
|
||||
return data ? { kind: "product", data } : null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const orderContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
|
||||
const products = data.productInfoList ?? data.productList;
|
||||
if (!Array.isArray(products)) return null;
|
||||
const projected = products.map(productFor);
|
||||
const title = text(data.cardTitle);
|
||||
const statusText = text(data.orderStatusText);
|
||||
const shippingAddress = text(data.shippingAddress);
|
||||
const totalDisplay = text(data.shouldPayAmount) ?? text(data.productAmount);
|
||||
if (
|
||||
!title ||
|
||||
!statusText ||
|
||||
!shippingAddress ||
|
||||
!totalDisplay ||
|
||||
projected.some((item) => item === null)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const action = isObjectRecord(data.orderAction) ? data.orderAction : {};
|
||||
return {
|
||||
version: 1,
|
||||
kind: "rendered_order",
|
||||
title,
|
||||
products: projected as { imageUrl: string | null; title: string }[],
|
||||
productCount: nonNegativeInteger(data.productCount) ?? projected.length,
|
||||
status: { code: scalarId(data.orderStatus), text: statusText },
|
||||
payment: { totalDisplay, discountDisplay: nullableText(data.discountAmount) },
|
||||
delivery: {
|
||||
shippingAddress,
|
||||
methodLabel: nullableText(data.shippingMethodText),
|
||||
dateLabel: nullableText(data.deliveryTimeText),
|
||||
},
|
||||
action: {
|
||||
label: nullableText(action.label) ?? nullableText(action.name),
|
||||
status: nullableText(action.status),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const inquiryActionsFor = (data: Record<string, unknown>) =>
|
||||
[
|
||||
[data.detailStr, data.detailAction],
|
||||
[data.quoteStr, data.quoteAction],
|
||||
[data.actionName, data.inquiryAction],
|
||||
].flatMap(([label, action]) => {
|
||||
const approvedLabel = text(label);
|
||||
return approvedLabel ? [{ label: approvedLabel, available: isObjectRecord(action) }] : [];
|
||||
});
|
||||
|
||||
const inquiryContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
|
||||
const products = data.displayProducts ?? data.products;
|
||||
const requirementText = text(data.inquiryContent);
|
||||
const inquiryReference = scalarId(data.inquiryID);
|
||||
if (!Array.isArray(products) || !requirementText || !inquiryReference) return null;
|
||||
const product = productFor(products[0]);
|
||||
if (!product || !isObjectRecord(products[0])) return null;
|
||||
const value = text(products[0].productMOQ);
|
||||
const unit = text(products[0].productUnit);
|
||||
if (!value || !unit) return null;
|
||||
return {
|
||||
version: 1,
|
||||
kind: "rendered_inquiry",
|
||||
product,
|
||||
purchaseQuantity: { value, unit },
|
||||
requirementText,
|
||||
inquiryReference,
|
||||
actions: inquiryActionsFor(data),
|
||||
};
|
||||
};
|
||||
|
||||
const productContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
|
||||
const productAction = data.productAction;
|
||||
if (!isObjectRecord(productAction) || !isObjectRecord(productAction.actionParams)) return null;
|
||||
const reference = normalizeOneTalkProductUrl(productAction.actionParams.url);
|
||||
const title = text(data.title);
|
||||
const productImage = imageUrl(data.productImage);
|
||||
const priceDisplay = text(data.price);
|
||||
const value = text(data.moq);
|
||||
const unit = text(data.moqUnit);
|
||||
if (!reference || !title || !productImage || !priceDisplay || !value || !unit) return null;
|
||||
const serviceBadges = [data.benefitFirstText, data.benefitSecondText, data.promotionTag].reduce(
|
||||
(badges: string[], value) => {
|
||||
const badge = text(value);
|
||||
return badge && !badges.includes(badge) ? [...badges, badge] : badges;
|
||||
},
|
||||
[],
|
||||
);
|
||||
return {
|
||||
version: 1,
|
||||
kind: "rendered_product",
|
||||
product: { title, imageUrl: productImage, ...reference },
|
||||
priceDisplay,
|
||||
minimumOrder: { value, unit },
|
||||
serviceBadges,
|
||||
};
|
||||
};
|
||||
|
||||
const contentFor = (classification: CardClassification): OneTalkRenderedCardContent | null => {
|
||||
const content =
|
||||
classification.kind === "order"
|
||||
? orderContentFor(classification.data)
|
||||
: classification.kind === "inquiry"
|
||||
? inquiryContentFor(classification.data)
|
||||
: productContentFor(classification.data);
|
||||
return content && isOneTalkRenderedCardContent(content) ? content : null;
|
||||
};
|
||||
|
||||
/** 读取一次受限 Fiber 卡片,并同时返回 downstream 所需的基础消息证据。 */
|
||||
export const readOneTalkRenderedCard = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
wrapper: Element,
|
||||
): OneTalkRenderedCardRead | null => {
|
||||
const before = readConversationSelection(pageWindow);
|
||||
if (before.kind !== "single") return null;
|
||||
const fiber = fiberIn(wrapper);
|
||||
if (!fiber) return null;
|
||||
const item = itemDataFor(fiber);
|
||||
if (!item || item.conversationCode !== before.conversationId) return null;
|
||||
const classification = classifyRenderedCard(item, fiber);
|
||||
if (!classification) return null;
|
||||
const content = contentFor(classification);
|
||||
const after = readConversationSelection(pageWindow);
|
||||
if (!content || after.kind !== "single" || after.conversationId !== before.conversationId)
|
||||
return null;
|
||||
const observation = {
|
||||
conversationId: item.conversationCode,
|
||||
messageId: item.messageId,
|
||||
content,
|
||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
||||
observedAtMs: Date.now(),
|
||||
};
|
||||
return {
|
||||
observation,
|
||||
baseEvidence: {
|
||||
conversationId: item.conversationCode,
|
||||
messageId: item.messageId,
|
||||
direction: item.messageType === "rec" ? "received" : "sent",
|
||||
sentAtMs: item.sendTime,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<string, unknown>,
|
||||
): 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<string, unknown>[];
|
||||
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 })),
|
||||
});
|
||||
|
||||
/** 创建发送到页面的命令消息。 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void>;
|
||||
observe: (observations: OneTalkRenderedCardObservation[]) => Promise<void>;
|
||||
handleFrame: (frame: OneTalkFrame) => void;
|
||||
handleStatus: (state: OneTalkBrightClientState) => void;
|
||||
handlePageReady: () => Promise<void>;
|
||||
@@ -89,29 +85,12 @@ export const createOneTalkRenderedCardCoordinator = (options: {
|
||||
report(error);
|
||||
}
|
||||
};
|
||||
const observe = async (
|
||||
observations: OneTalkRenderedCardObservation[],
|
||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence[] = [],
|
||||
): Promise<void> => {
|
||||
const observe = async (observations: OneTalkRenderedCardObservation[]): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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<OneTalkRenderedCardLedgerRecord>;
|
||||
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<OneTalkRenderedCardLedgerRecord> => {
|
||||
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<OneTalkRenderedCardLedgerRecord> => {
|
||||
const key = candidateKey(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
@@ -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')));
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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'))`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ export type OneTalkRenderedCardRepository = {
|
||||
store: (input: {
|
||||
channelAccountId: string;
|
||||
observation: OneTalkRenderedCardObservation;
|
||||
receivedAt: Date;
|
||||
commitGuard?: OneTalkCommitGuard;
|
||||
}) => Promise<OneTalkRenderedCardStoreResult>;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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<unknown>) =>
|
||||
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"]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
@@ -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))
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = <T>(
|
||||
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<OneTalkRenderedCard["productCount"]> =>
|
||||
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。 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user