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` 保持原生媒体上传拦截器和关联状态,不复制或搬迁模块级状态容器。
|
- `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。
|
- 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。
|
- 新 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
|
#### Validation & Error Matrix
|
||||||
|
|
||||||
@@ -109,8 +109,8 @@ Bright WebSocket
|
|||||||
| 未知 action | rejected_before_send/invalid_request,不访问 SDK 或 snapshot |
|
| 未知 action | rejected_before_send/invalid_request,不访问 SDK 或 snapshot |
|
||||||
| send 的 SDK 本地接受、超时或歧义 | 保持既有发送 SOP,不增加重试或假成功 |
|
| send 的 SDK 本地接受、超时或歧义 | 保持既有发送 SOP,不增加重试或假成功 |
|
||||||
| 历史命令返回 batch | 使用同一 publisher,不新建发送关联器或上报管线 |
|
| 历史命令返回 batch | 使用同一 publisher,不新建发送关联器或上报管线 |
|
||||||
| rendered-card wrapper 先挂载、后补齐 Fiber/template | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 |
|
| rendered-card 已挂载后文本/图片或 metadata 才补齐 | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 |
|
||||||
| rendered-card reader 仍无完整白名单模板 | 不发布、不写 ledger;仅等待该 wrapper 的下一次子树变化 |
|
| identity、可见快照或安全图片 URL 不完整 | 不发布、不写 ledger;仅等待该 card 的下一次子树变化 |
|
||||||
|
|
||||||
#### Good / Base / Bad Cases
|
#### 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-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-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 联调需单独标记。
|
- 对构建后的 MAIN IIFE 执行 `scripts/verify-release-bundle.mjs`,验证实际 shared observation 驱动消息上报及单次发送确认;这属于无网络 Node VM 冒烟,真实 Chromium/OneTalk/Bright 联调需单独标记。
|
||||||
|
|
||||||
#### Wrong vs Correct
|
#### Wrong vs Correct
|
||||||
|
|||||||
@@ -387,7 +387,7 @@ toOneTalkCenterMessage(
|
|||||||
): OneTalkCenterMessage;
|
): OneTalkCenterMessage;
|
||||||
```
|
```
|
||||||
|
|
||||||
`OneTalkCenterMessageContent` 的 `inquiry`、`product`、`order` 分支分别可增加已批准的展示字段;`OneTalkMessageContent` 与 `OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。
|
`OneTalkCenterMessageContent` 的 legacy `inquiry`、`product`、`order` 分支可保留已批准的历史展示字段;新的 type-agnostic named supplement 通过 `OneTalkCenterMessage.renderedCard?: OneTalkRenderedCard` 公开。`OneTalkMessageContent` 与 `OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。
|
||||||
|
|
||||||
### 3. Contracts
|
### 3. Contracts
|
||||||
|
|
||||||
@@ -396,6 +396,7 @@ toOneTalkCenterMessage(
|
|||||||
- product 的 `sourceUrl` 与 `productId` 永远来自基础消息,且必须与补全中的身份一致。补全 kind 或 product 身份不一致、补全形状非法时显式失败,不能丢弃补全、猜测类型或发布部分内容。
|
- product 的 `sourceUrl` 与 `productId` 永远来自基础消息,且必须与补全中的身份一致。补全 kind 或 product 身份不一致、补全形状非法时显式失败,不能丢弃补全、猜测类型或发布部分内容。
|
||||||
- `projectCenterMessage`、rendered-card update、live/history sync 和 send-confirmation 都只把基础 message 与 optional supplement 传给 `toOneTalkCenterMessage`;不得在调用点以 `renderedCardContent ?? message.content` 替换内容。
|
- `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 顺序保持不变。
|
- 补全先到时,后续单条或批量 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
|
### 4. Validation & Error Matrix
|
||||||
|
|
||||||
@@ -406,18 +407,19 @@ toOneTalkCenterMessage(
|
|||||||
| rendered product 的 URL 或 product ID 不一致 | 投影失败,不以补全覆盖基础身份 |
|
| rendered product 的 URL 或 product ID 不一致 | 投影失败,不以补全覆盖基础身份 |
|
||||||
| unknown key、错误嵌套或 ledger 字段进入 Center content | strict guard 拒绝 |
|
| unknown key、错误嵌套或 ledger 字段进入 Center content | strict guard 拒绝 |
|
||||||
| supplement 先到、base message 后由 batch live accepted | repository 带回 supplement;`message.created` 使用基础 kind 加批准字段 |
|
| 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/冲突语义;不改写基础消息事实 |
|
| duplicate/conflict supplement 或补全写失败 | 保持既有 ACK/冲突语义;不改写基础消息事实 |
|
||||||
|
|
||||||
### 5. Good / Base / Bad Cases
|
### 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`。
|
- Base:基础 message 先到且没有 supplement 时,后续读取保留原始 shape;补全到达后只发一次同一基础 kind 的 `message.updated`。
|
||||||
- Bad:repository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。
|
- Bad:repository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。
|
||||||
|
|
||||||
### 6. Tests Required
|
### 6. Tests Required
|
||||||
|
|
||||||
- Contract:三种基础 kind 的无补全 exact shape、补全后的批准可选字段、unknown key/错误嵌套拒绝,以及 supplement kind/product identity 不一致拒绝。
|
- Contract:generic `renderedCard` 的 exact shape、unknown key/错误嵌套拒绝,以及 legacy supplement kind/product identity 不一致拒绝。
|
||||||
- Read/WebSocket:HTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖补全后到、补全先到后单条 live、补全先到后 batch live 和 send confirmation,断言公开 kind 永不为 `rendered_*`。
|
- Read/WebSocket:HTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖 generic supplement 后到、先到后单条 live、先到后 batch live,断言基础 content 不变而仅顶层 snapshot 出现。
|
||||||
- Repository:batch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。
|
- Repository:batch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。
|
||||||
|
|
||||||
### 7. Wrong vs Correct
|
### 7. Wrong vs Correct
|
||||||
@@ -426,7 +428,7 @@ toOneTalkCenterMessage(
|
|||||||
// Wrong: internal supplement replaces the public discriminated content.
|
// Wrong: internal supplement replaces the public discriminated content.
|
||||||
toOneTalkCenterMessage({ ...message, content: renderedCardContent ?? message.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);
|
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 { OneTalkRenderedCardObservation } from "@trade-message-center/onetalk-contract";
|
||||||
import type { OneTalkPageWindow } from "../model.ts";
|
import type { OneTalkPageWindow } from "../model.ts";
|
||||||
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
|
import { readOneTalkRenderedCardFromDom } from "./dom-card-reader.ts";
|
||||||
import { readOneTalkRenderedCard } from "./react-card-reader.ts";
|
|
||||||
|
|
||||||
const RENDERED_CARD_RESCAN_INTERVAL_MS = 1_000;
|
const CARD_SELECTOR = ".session-rich-content.card";
|
||||||
|
|
||||||
const messageListRootFor = (
|
const isCardElement = (value: unknown): value is Element =>
|
||||||
document: NonNullable<OneTalkPageWindow["document"]>,
|
typeof value === "object" &&
|
||||||
): Element | null => {
|
value !== null &&
|
||||||
const firstMessage = document.querySelectorAll(".message-item-wrapper")[0];
|
"matches" in value &&
|
||||||
if (!firstMessage) return null;
|
typeof (value as { matches?: unknown }).matches === "function";
|
||||||
let parent = firstMessage.parentElement;
|
|
||||||
while (parent) {
|
const cardsFor = (node: unknown): Element[] => {
|
||||||
if (parent.querySelectorAll(".message-item-wrapper").length > 1) return parent;
|
const element = isCardElement(node)
|
||||||
parent = parent.parentElement;
|
? node
|
||||||
}
|
: typeof node === "object" && node !== null && "parentElement" in node
|
||||||
return firstMessage.parentElement;
|
? (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 => {
|
/** 安装当前页面的严格 DOM 卡片采集;未知或不完整卡不发送。 */
|
||||||
const element = node instanceof Element ? node : node.parentElement;
|
|
||||||
return element?.matches(".message-item-wrapper")
|
|
||||||
? element
|
|
||||||
: (element?.closest(".message-item-wrapper") ?? null);
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 安装 Fiber card reader;未验证类别一律不上传。 */
|
|
||||||
export const installOneTalkRenderedCardObserver = (
|
export const installOneTalkRenderedCardObserver = (
|
||||||
pageWindow: OneTalkPageWindow,
|
pageWindow: OneTalkPageWindow,
|
||||||
sink: (
|
sink: (observations: OneTalkRenderedCardObservation[]) => void,
|
||||||
observations: OneTalkRenderedCardObservation[],
|
|
||||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
|
|
||||||
) => void,
|
|
||||||
): void => {
|
): void => {
|
||||||
const document = pageWindow.document;
|
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>();
|
const observedCardKeys = new Set<string>();
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
const inspect = (wrapper: Element): void => {
|
const publish = (card: Element): void => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
const read = readOneTalkRenderedCard(pageWindow, wrapper);
|
const observation = readOneTalkRenderedCardFromDom(pageWindow, card);
|
||||||
if (!read) return;
|
if (!observation) return;
|
||||||
const observationKey = JSON.stringify([
|
const key = JSON.stringify([
|
||||||
read.observation.conversationId,
|
observation.conversationId,
|
||||||
read.observation.messageId,
|
observation.messageId,
|
||||||
read.observation.contentFingerprint,
|
observation.contentFingerprint,
|
||||||
]);
|
]);
|
||||||
if (observedCardKeys.has(observationKey)) return;
|
if (observedCardKeys.has(key)) return;
|
||||||
observedCardKeys.add(observationKey);
|
observedCardKeys.add(key);
|
||||||
sink([read.observation], [read.baseEvidence]);
|
sink([observation]);
|
||||||
};
|
};
|
||||||
let root: Element | null = null;
|
|
||||||
let observingDocument = false;
|
|
||||||
const observer = new pageWindow.MutationObserver((records) => {
|
const observer = new pageWindow.MutationObserver((records) => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (messageListRootFor(document) !== root) {
|
|
||||||
bind();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
if (root && record.target !== root && !root.contains(record.target)) continue;
|
for (const card of cardsFor(record.target)) publish(card);
|
||||||
const wrappers = new Set<Element>();
|
|
||||||
const targetWrapper = renderedCardWrapperFor(record.target);
|
|
||||||
if (targetWrapper) wrappers.add(targetWrapper);
|
|
||||||
for (const node of Array.from(record.addedNodes)) {
|
for (const node of Array.from(record.addedNodes)) {
|
||||||
if (!(node instanceof Element)) continue;
|
for (const card of cardsFor(node)) publish(card);
|
||||||
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 wrapper of wrappers) inspect(wrapper);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const bind = (): void => {
|
for (const card of Array.from(document.querySelectorAll(CARD_SELECTOR))) publish(card);
|
||||||
if (disposed) return;
|
observer.observe(documentElement, {
|
||||||
const nextRoot = messageListRootFor(document);
|
childList: true,
|
||||||
if (nextRoot === root && (root !== null || observingDocument)) return;
|
subtree: true,
|
||||||
observer.disconnect();
|
characterData: true,
|
||||||
root = nextRoot;
|
attributes: true,
|
||||||
if (!root) {
|
attributeFilter: ["data-expinfo", "src", "style"],
|
||||||
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);
|
|
||||||
pageWindow.addEventListener("pagehide", () => {
|
pageWindow.addEventListener("pagehide", () => {
|
||||||
disposed = true;
|
disposed = true;
|
||||||
globalThis.clearInterval(rescanTimer);
|
|
||||||
observer.disconnect();
|
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,
|
createOneTalkPageRenderedCardObservedMessage,
|
||||||
ONE_TALK_PAGE_BRIDGE_RECONNECT_SIGNAL,
|
ONE_TALK_PAGE_BRIDGE_RECONNECT_SIGNAL,
|
||||||
type PageCommandResult,
|
type PageCommandResult,
|
||||||
type OneTalkPageRenderedCardBaseEvidence,
|
|
||||||
} from "./model.ts";
|
} from "./model.ts";
|
||||||
|
|
||||||
export type OneTalkPageCommandHandler = (
|
export type OneTalkPageCommandHandler = (
|
||||||
@@ -224,23 +223,16 @@ export const createOneTalkPageObservedSink = (
|
|||||||
/** 创建只发送经 MAIN 白名单投影的渲染卡片补全 sink。 */
|
/** 创建只发送经 MAIN 白名单投影的渲染卡片补全 sink。 */
|
||||||
export const createOneTalkPageRenderedCardObservedSink = (
|
export const createOneTalkPageRenderedCardObservedSink = (
|
||||||
pageWindow: OneTalkMainPageBridgeWindow,
|
pageWindow: OneTalkMainPageBridgeWindow,
|
||||||
): ((
|
): ((observations: OneTalkRenderedCardObservation[]) => void) => {
|
||||||
observations: OneTalkRenderedCardObservation[],
|
|
||||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
|
|
||||||
) => void) => {
|
|
||||||
const origin = pageOrigin(pageWindow);
|
const origin = pageOrigin(pageWindow);
|
||||||
return (observations, baseEvidence: OneTalkPageRenderedCardBaseEvidence[]) => {
|
return (observations) => {
|
||||||
if (!origin || observations.length === 0) return;
|
if (!origin || observations.length === 0) return;
|
||||||
const channelAccountId = readChannelAccountId(pageWindow);
|
const channelAccountId = readChannelAccountId(pageWindow);
|
||||||
if (!channelAccountId) return;
|
if (!channelAccountId) return;
|
||||||
postPageMessage(
|
postPageMessage(
|
||||||
pageWindow,
|
pageWindow,
|
||||||
origin,
|
origin,
|
||||||
createOneTalkPageRenderedCardObservedMessage(
|
createOneTalkPageRenderedCardObservedMessage(channelAccountId, observations),
|
||||||
channelAccountId,
|
|
||||||
observations,
|
|
||||||
baseEvidence,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
isOneTalkContactProfile,
|
isOneTalkContactProfile,
|
||||||
isOneTalkMessage,
|
isOneTalkMessage,
|
||||||
isOneTalkBuyerFact,
|
isOneTalkBuyerFact,
|
||||||
isOneTalkRenderedCardContent,
|
isOneTalkRenderedCard,
|
||||||
type OneTalkBuyerFact,
|
type OneTalkBuyerFact,
|
||||||
type OneTalkContactProfile,
|
type OneTalkContactProfile,
|
||||||
type OneTalkRenderedCardObservation,
|
type OneTalkRenderedCardObservation,
|
||||||
@@ -82,22 +82,13 @@ export type OneTalkPageBuyerFactsObservedMessage = {
|
|||||||
facts: OneTalkBuyerFact[];
|
facts: OneTalkBuyerFact[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** MAIN 已完成白名单投影的渲染卡片补全;不携带 DOM/Fiber/raw props。 */
|
/** MAIN 已完成白名单投影的渲染卡片补全;不携带原始页面对象。 */
|
||||||
export type OneTalkPageRenderedCardObservedMessage = {
|
export type OneTalkPageRenderedCardObservedMessage = {
|
||||||
source: typeof ONE_TALK_PAGE_BRIDGE_SOURCE;
|
source: typeof ONE_TALK_PAGE_BRIDGE_SOURCE;
|
||||||
version: typeof ONE_TALK_PAGE_BRIDGE_VERSION;
|
version: typeof ONE_TALK_PAGE_BRIDGE_VERSION;
|
||||||
type: "onetalk.page.rendered-card-observed";
|
type: "onetalk.page.rendered-card-observed";
|
||||||
channelAccountId: string;
|
channelAccountId: string;
|
||||||
observations: OneTalkRenderedCardObservation[];
|
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 = {
|
export type OneTalkPageHistoryProgress = {
|
||||||
@@ -531,14 +522,7 @@ const decodeRenderedCardObservedMessage = (
|
|||||||
value: Record<string, unknown>,
|
value: Record<string, unknown>,
|
||||||
): OneTalkPageRenderedCardObservedMessage | null => {
|
): OneTalkPageRenderedCardObservedMessage | null => {
|
||||||
if (
|
if (
|
||||||
!hasExactKeys(value, [
|
!hasExactKeys(value, ["source", "version", "type", "channelAccountId", "observations"]) ||
|
||||||
"source",
|
|
||||||
"version",
|
|
||||||
"type",
|
|
||||||
"channelAccountId",
|
|
||||||
"observations",
|
|
||||||
"baseEvidence",
|
|
||||||
]) ||
|
|
||||||
typeof value.channelAccountId !== "string" ||
|
typeof value.channelAccountId !== "string" ||
|
||||||
value.channelAccountId.trim().length === 0 ||
|
value.channelAccountId.trim().length === 0 ||
|
||||||
!Array.isArray(value.observations) ||
|
!Array.isArray(value.observations) ||
|
||||||
@@ -561,32 +545,13 @@ const decodeRenderedCardObservedMessage = (
|
|||||||
typeof observation.observedAtMs === "number" &&
|
typeof observation.observedAtMs === "number" &&
|
||||||
Number.isSafeInteger(observation.observedAtMs) &&
|
Number.isSafeInteger(observation.observedAtMs) &&
|
||||||
observation.observedAtMs >= 0 &&
|
observation.observedAtMs >= 0 &&
|
||||||
isOneTalkRenderedCardContent(observation.content) &&
|
isOneTalkRenderedCard(observation.content) &&
|
||||||
observation.contentFingerprint ===
|
observation.contentFingerprint ===
|
||||||
createOneTalkRenderedCardContentFingerprint(observation.content),
|
createOneTalkRenderedCardContentFingerprint(observation.content),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return null;
|
return null;
|
||||||
const observations = value.observations as Record<string, unknown>[];
|
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 {
|
return {
|
||||||
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
|
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
|
||||||
version: ONE_TALK_PAGE_BRIDGE_VERSION,
|
version: ONE_TALK_PAGE_BRIDGE_VERSION,
|
||||||
@@ -599,12 +564,6 @@ const decodeRenderedCardObservedMessage = (
|
|||||||
contentFingerprint: observation.contentFingerprint as string,
|
contentFingerprint: observation.contentFingerprint as string,
|
||||||
observedAtMs: observation.observedAtMs as number,
|
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 = (
|
export const createOneTalkPageRenderedCardObservedMessage = (
|
||||||
channelAccountId: string,
|
channelAccountId: string,
|
||||||
observations: OneTalkRenderedCardObservation[],
|
observations: OneTalkRenderedCardObservation[],
|
||||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
|
|
||||||
): OneTalkPageRenderedCardObservedMessage => ({
|
): OneTalkPageRenderedCardObservedMessage => ({
|
||||||
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
|
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
|
||||||
version: ONE_TALK_PAGE_BRIDGE_VERSION,
|
version: ONE_TALK_PAGE_BRIDGE_VERSION,
|
||||||
@@ -802,7 +760,6 @@ export const createOneTalkPageRenderedCardObservedMessage = (
|
|||||||
...observation,
|
...observation,
|
||||||
content: JSON.parse(JSON.stringify(observation.content)),
|
content: JSON.parse(JSON.stringify(observation.content)),
|
||||||
})),
|
})),
|
||||||
baseEvidence: baseEvidence.map((evidence) => ({ ...evidence })),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 创建发送到页面的命令消息。 */
|
/** 创建发送到页面的命令消息。 */
|
||||||
|
|||||||
@@ -321,7 +321,7 @@ export class OneTalkPageRuntimeHost {
|
|||||||
const coordinator = options.getActiveRenderedCardCoordinator?.();
|
const coordinator = options.getActiveRenderedCardCoordinator?.();
|
||||||
if (!coordinator || channelAccountId !== this.lastPageIdentity?.channelAccountId)
|
if (!coordinator || channelAccountId !== this.lastPageIdentity?.channelAccountId)
|
||||||
return;
|
return;
|
||||||
await coordinator.observe(message.observations, message.baseEvidence);
|
await coordinator.observe(message.observations);
|
||||||
void sender;
|
void sender;
|
||||||
},
|
},
|
||||||
onPageMessage: () => undefined,
|
onPageMessage: () => undefined,
|
||||||
|
|||||||
@@ -8,13 +8,9 @@ import {
|
|||||||
} from "@trade-message-center/onetalk-contract";
|
} from "@trade-message-center/onetalk-contract";
|
||||||
import type { OneTalkBrightClient, OneTalkBrightClientState } from "./transport/bright-client.ts";
|
import type { OneTalkBrightClient, OneTalkBrightClientState } from "./transport/bright-client.ts";
|
||||||
import type { OneTalkRenderedCardLedgerStore } from "./storage.ts";
|
import type { OneTalkRenderedCardLedgerStore } from "./storage.ts";
|
||||||
import type { OneTalkPageRenderedCardBaseEvidence } from "../page-bridge/model.ts";
|
|
||||||
|
|
||||||
export type OneTalkRenderedCardCoordinator = {
|
export type OneTalkRenderedCardCoordinator = {
|
||||||
observe: (
|
observe: (observations: OneTalkRenderedCardObservation[]) => Promise<void>;
|
||||||
observations: OneTalkRenderedCardObservation[],
|
|
||||||
baseEvidence?: OneTalkPageRenderedCardBaseEvidence[],
|
|
||||||
) => Promise<void>;
|
|
||||||
handleFrame: (frame: OneTalkFrame) => void;
|
handleFrame: (frame: OneTalkFrame) => void;
|
||||||
handleStatus: (state: OneTalkBrightClientState) => void;
|
handleStatus: (state: OneTalkBrightClientState) => void;
|
||||||
handlePageReady: () => Promise<void>;
|
handlePageReady: () => Promise<void>;
|
||||||
@@ -89,29 +85,12 @@ export const createOneTalkRenderedCardCoordinator = (options: {
|
|||||||
report(error);
|
report(error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const observe = async (
|
const observe = async (observations: OneTalkRenderedCardObservation[]): Promise<void> => {
|
||||||
observations: OneTalkRenderedCardObservation[],
|
|
||||||
baseEvidence: OneTalkPageRenderedCardBaseEvidence[] = [],
|
|
||||||
): Promise<void> => {
|
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (
|
for (const observation of observations) {
|
||||||
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]!;
|
|
||||||
await options.ledger.observe({
|
await options.ledger.observe({
|
||||||
channelAccountId: options.scope.channelAccountId,
|
channelAccountId: options.scope.channelAccountId,
|
||||||
observation,
|
observation,
|
||||||
baseEvidence: {
|
|
||||||
direction: evidence.direction,
|
|
||||||
sentAtMs: evidence.sentAtMs,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 持久化 OneTalk 同步账本与页面观察
|
// 持久化 OneTalk 同步账本与页面观察
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cloneOneTalkRenderedCardContent,
|
cloneOneTalkRenderedCard,
|
||||||
isOneTalkMessage,
|
isOneTalkMessage,
|
||||||
type OneTalkContactProfile,
|
type OneTalkContactProfile,
|
||||||
type OneTalkObservedMessage,
|
type OneTalkObservedMessage,
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
type OneTalkSyncAnomalyCode,
|
type OneTalkSyncAnomalyCode,
|
||||||
type OneTalkSyncMode,
|
type OneTalkSyncMode,
|
||||||
type OneTalkSyncResult,
|
type OneTalkSyncResult,
|
||||||
type OneTalkRenderedCardContent,
|
type OneTalkRenderedCard,
|
||||||
type OneTalkRenderedCardObservation,
|
type OneTalkRenderedCardObservation,
|
||||||
} from "@trade-message-center/onetalk-contract";
|
} from "@trade-message-center/onetalk-contract";
|
||||||
|
|
||||||
@@ -141,13 +141,9 @@ export type OneTalkRenderedCardLedgerRecord = {
|
|||||||
channelAccountId: string;
|
channelAccountId: string;
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
content: OneTalkRenderedCardContent;
|
content: OneTalkRenderedCard;
|
||||||
contentFingerprint: string;
|
contentFingerprint: string;
|
||||||
observedAtMs: number;
|
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";
|
status: "pending_ack" | "confirmed" | "rejected";
|
||||||
requestId?: string;
|
requestId?: string;
|
||||||
firstObservedAt: number;
|
firstObservedAt: number;
|
||||||
@@ -162,10 +158,6 @@ export type OneTalkRenderedCardLedgerStore = {
|
|||||||
observe: (input: {
|
observe: (input: {
|
||||||
channelAccountId: string;
|
channelAccountId: string;
|
||||||
observation: OneTalkRenderedCardObservation;
|
observation: OneTalkRenderedCardObservation;
|
||||||
baseEvidence: {
|
|
||||||
direction: "received" | "sent";
|
|
||||||
sentAtMs: number;
|
|
||||||
};
|
|
||||||
observedAt?: number;
|
observedAt?: number;
|
||||||
}) => Promise<OneTalkRenderedCardLedgerRecord>;
|
}) => Promise<OneTalkRenderedCardLedgerRecord>;
|
||||||
get: (
|
get: (
|
||||||
@@ -536,7 +528,7 @@ export const createOneTalkRenderedCardLedgerStore = (
|
|||||||
const completion = transactionResult(transaction);
|
const completion = transactionResult(transaction);
|
||||||
transaction.objectStore(ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME).put({
|
transaction.objectStore(ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME).put({
|
||||||
...value,
|
...value,
|
||||||
content: cloneOneTalkRenderedCardContent(value.content),
|
content: cloneOneTalkRenderedCard(value.content),
|
||||||
});
|
});
|
||||||
await completion;
|
await completion;
|
||||||
};
|
};
|
||||||
@@ -556,7 +548,6 @@ export const createOneTalkRenderedCardLedgerStore = (
|
|||||||
const observeOne = async (input: {
|
const observeOne = async (input: {
|
||||||
channelAccountId: string;
|
channelAccountId: string;
|
||||||
observation: OneTalkRenderedCardObservation;
|
observation: OneTalkRenderedCardObservation;
|
||||||
baseEvidence: { direction: "received" | "sent"; sentAtMs: number };
|
|
||||||
observedAt?: number;
|
observedAt?: number;
|
||||||
}): Promise<OneTalkRenderedCardLedgerRecord> => {
|
}): Promise<OneTalkRenderedCardLedgerRecord> => {
|
||||||
const current = await get(
|
const current = await get(
|
||||||
@@ -589,11 +580,9 @@ export const createOneTalkRenderedCardLedgerStore = (
|
|||||||
channelAccountId: input.channelAccountId,
|
channelAccountId: input.channelAccountId,
|
||||||
conversationId: input.observation.conversationId,
|
conversationId: input.observation.conversationId,
|
||||||
messageId: input.observation.messageId,
|
messageId: input.observation.messageId,
|
||||||
content: cloneOneTalkRenderedCardContent(input.observation.content),
|
content: cloneOneTalkRenderedCard(input.observation.content),
|
||||||
contentFingerprint: input.observation.contentFingerprint,
|
contentFingerprint: input.observation.contentFingerprint,
|
||||||
observedAtMs: input.observation.observedAtMs,
|
observedAtMs: input.observation.observedAtMs,
|
||||||
baseDirection: input.baseEvidence.direction,
|
|
||||||
baseSentAtMs: input.baseEvidence.sentAtMs,
|
|
||||||
status: "pending_ack",
|
status: "pending_ack",
|
||||||
firstObservedAt: observedAt,
|
firstObservedAt: observedAt,
|
||||||
updatedAt: observedAt,
|
updatedAt: observedAt,
|
||||||
@@ -604,7 +593,6 @@ export const createOneTalkRenderedCardLedgerStore = (
|
|||||||
const observe = (input: {
|
const observe = (input: {
|
||||||
channelAccountId: string;
|
channelAccountId: string;
|
||||||
observation: OneTalkRenderedCardObservation;
|
observation: OneTalkRenderedCardObservation;
|
||||||
baseEvidence: { direction: "received" | "sent"; sentAtMs: number };
|
|
||||||
observedAt?: number;
|
observedAt?: number;
|
||||||
}): Promise<OneTalkRenderedCardLedgerRecord> => {
|
}): Promise<OneTalkRenderedCardLedgerRecord> => {
|
||||||
const key = candidateKey(
|
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 = {
|
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,
|
version: 1,
|
||||||
kind: "rendered_order",
|
kind: "rendered_order",
|
||||||
title: "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 },
|
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
|
||||||
action: { label: null, status: null },
|
action: { label: null, status: null },
|
||||||
};
|
};
|
||||||
const observation = {
|
const legacyObservation = {
|
||||||
conversationId: "conversation-1",
|
...observation,
|
||||||
messageId: "message-1",
|
content: legacyContent,
|
||||||
content,
|
contentFingerprint: createOneTalkRenderedCardContentFingerprint(legacyContent),
|
||||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
|
||||||
observedAtMs: 1_700_000_000_000,
|
|
||||||
};
|
};
|
||||||
const message = createOneTalkPageRenderedCardObservedMessage(
|
assert.equal(decodeOneTalkPageMessage({ ...message, observations: [legacyObservation] }), null);
|
||||||
"account-1",
|
|
||||||
[observation],
|
|
||||||
[
|
|
||||||
{
|
|
||||||
conversationId: observation.conversationId,
|
|
||||||
messageId: observation.messageId,
|
|
||||||
direction: "received",
|
|
||||||
sentAtMs: 1_699_999_999_999,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
);
|
|
||||||
assert.deepEqual(decodeOneTalkPageMessage(message), message);
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
decodeOneTalkPageMessage({
|
decodeOneTalkPageMessage({
|
||||||
...message,
|
...message,
|
||||||
@@ -366,7 +369,7 @@ test("rejects rendered-card observations with forged identity or fingerprint fie
|
|||||||
assert.equal(decodeOneTalkPageMessage({ ...message, baseEvidence: [] }), null);
|
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 = {
|
const content = {
|
||||||
version: 1,
|
version: 1,
|
||||||
kind: "rendered_product",
|
kind: "rendered_product",
|
||||||
@@ -393,47 +396,9 @@ test("decodes canonical rendered products and rejects forged product references"
|
|||||||
type: "onetalk.page.rendered-card-observed",
|
type: "onetalk.page.rendered-card-observed",
|
||||||
channelAccountId: "account-1",
|
channelAccountId: "account-1",
|
||||||
observations: [observation],
|
observations: [observation],
|
||||||
baseEvidence: [
|
|
||||||
{
|
|
||||||
conversationId: observation.conversationId,
|
|
||||||
messageId: observation.messageId,
|
|
||||||
direction: "received",
|
|
||||||
sentAtMs: 1_699_999_999_999,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.deepEqual(decodeOneTalkPageMessage(message), message);
|
assert.equal(decodeOneTalkPageMessage(message), null);
|
||||||
|
|
||||||
for (const invalidContent of [
|
|
||||||
{ ...content, storeImageUrl: "https://img.alicdn.com/store.png" },
|
|
||||||
{
|
|
||||||
...content,
|
|
||||||
product: {
|
|
||||||
...content.product,
|
|
||||||
sourceUrl: "https://img.alicdn.com/product/widget.jpg",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...content,
|
|
||||||
product: { ...content.product, productId: "987654321" },
|
|
||||||
},
|
|
||||||
]) {
|
|
||||||
assert.equal(
|
|
||||||
decodeOneTalkPageMessage({
|
|
||||||
...message,
|
|
||||||
observations: [
|
|
||||||
{
|
|
||||||
...observation,
|
|
||||||
content: invalidContent,
|
|
||||||
contentFingerprint:
|
|
||||||
createOneTalkRenderedCardContentFingerprint(invalidContent),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("deep-clones extended buyer facts at the page bridge boundary", () => {
|
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 assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
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 scope = { channelAccountId: "account-1", deviceId: "device-1" };
|
||||||
const content = {
|
const content = {
|
||||||
version: 1,
|
version: 1,
|
||||||
kind: "rendered_order",
|
status: "Paid",
|
||||||
title: "Order",
|
total: "US $10.00",
|
||||||
products: [],
|
image: "https://img.alicdn.com/card.jpg",
|
||||||
productCount: 0,
|
|
||||||
status: { code: null, text: "Paid" },
|
|
||||||
payment: { totalDisplay: "$1", discountDisplay: null },
|
|
||||||
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
|
|
||||||
action: { label: null, status: null },
|
|
||||||
};
|
};
|
||||||
const observation = {
|
const observation = {
|
||||||
conversationId: "conversation-1",
|
conversationId: "conversation-1",
|
||||||
@@ -30,28 +25,17 @@ const key = JSON.stringify([
|
|||||||
observation.conversationId,
|
observation.conversationId,
|
||||||
observation.messageId,
|
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 () => {
|
test("writes the card ledger before immediate send, resends after reconnect, and exact-matches ACK", async () => {
|
||||||
const timeline = [];
|
const timeline = [];
|
||||||
const records = new Map();
|
const records = new Map();
|
||||||
const acknowledgements = [];
|
const acknowledgements = [];
|
||||||
const ledger = {
|
const ledger = {
|
||||||
observe: async ({ channelAccountId, observation: value, baseEvidence: evidence }) => {
|
observe: async ({ channelAccountId, observation: value }) => {
|
||||||
timeline.push("write");
|
timeline.push("write");
|
||||||
const record = {
|
const record = {
|
||||||
key,
|
key,
|
||||||
channelAccountId,
|
channelAccountId,
|
||||||
...value,
|
...value,
|
||||||
baseDirection: evidence.direction,
|
|
||||||
baseSentAtMs: evidence.sentAtMs,
|
|
||||||
status: "pending_ack",
|
status: "pending_ack",
|
||||||
firstObservedAt: 1,
|
firstObservedAt: 1,
|
||||||
updatedAt: 1,
|
updatedAt: 1,
|
||||||
@@ -105,7 +89,7 @@ test("writes the card ledger before immediate send, resends after reconnect, and
|
|||||||
createRequestId: () => "card-request",
|
createRequestId: () => "card-request",
|
||||||
});
|
});
|
||||||
|
|
||||||
await coordinator.observe([observation], baseEvidence);
|
await coordinator.observe([observation]);
|
||||||
assert.equal(frames.length, 1);
|
assert.equal(frames.length, 1);
|
||||||
assert.equal(frames[0].type, "rendered.card.observed");
|
assert.equal(frames[0].type, "rendered.card.observed");
|
||||||
assert.deepEqual(timeline, ["write", "send"]);
|
assert.deepEqual(timeline, ["write", "send"]);
|
||||||
@@ -152,8 +136,6 @@ test("recovers a pending record after worker restart without a base candidate",
|
|||||||
key,
|
key,
|
||||||
channelAccountId: scope.channelAccountId,
|
channelAccountId: scope.channelAccountId,
|
||||||
...observation,
|
...observation,
|
||||||
baseDirection: "received",
|
|
||||||
baseSentAtMs: 99,
|
|
||||||
status: "pending_ack",
|
status: "pending_ack",
|
||||||
firstObservedAt: 1,
|
firstObservedAt: 1,
|
||||||
updatedAt: 1,
|
updatedAt: 1,
|
||||||
|
|||||||
@@ -1,504 +1,191 @@
|
|||||||
// 验证渲染卡片 observer 在消息列表异步挂载时仍能建立监听。
|
// 验证 DOM card observer 只扫描渲染卡片并在页面关闭时释放监听。
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { installOneTalkRenderedCardObserver } from "../src/onetalk/main-page/card-observer/entry.ts";
|
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 observers = [];
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
|
const documentElement = {};
|
||||||
const page = {
|
const page = {
|
||||||
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
|
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
|
||||||
document: {
|
document: {
|
||||||
|
documentElement,
|
||||||
querySelectorAll: (selector) => {
|
querySelectorAll: (selector) => {
|
||||||
assert.equal(selector, ".message-item-wrapper");
|
if (selector === ".contact-item-container.selected[data-cid]")
|
||||||
return [];
|
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 {
|
MutationObserver: class {
|
||||||
constructor(callback) {
|
constructor(callback) {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
this.targets = [];
|
this.options = null;
|
||||||
observers.push(this);
|
observers.push(this);
|
||||||
}
|
}
|
||||||
|
observe(target, options) {
|
||||||
observe(target) {
|
assert.equal(target, documentElement);
|
||||||
this.targets.push(target);
|
this.options = options;
|
||||||
|
}
|
||||||
|
disconnect() {
|
||||||
|
this.disconnected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {}
|
|
||||||
},
|
},
|
||||||
addEventListener: (type, listener) => listeners.set(type, listener),
|
addEventListener: (type, listener) => listeners.set(type, listener),
|
||||||
};
|
};
|
||||||
|
const observed = [];
|
||||||
|
installOneTalkRenderedCardObserver(page, (observations) => observed.push(...observations));
|
||||||
|
|
||||||
installOneTalkRenderedCardObserver(page, () => undefined);
|
assert.equal(observed.length, 0);
|
||||||
|
assert.deepEqual(observers[0].options, {
|
||||||
assert.equal(observers.length, 1);
|
childList: true,
|
||||||
assert.equal(observers[0].targets.length, 1);
|
subtree: true,
|
||||||
assert.equal(listeners.has("pagehide"), 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")();
|
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 observers = [];
|
||||||
const listeners = new Map();
|
const listeners = new Map();
|
||||||
const rootParent = { parentElement: null, querySelectorAll: () => [] };
|
const documentElement = {};
|
||||||
const firstRoot = { parentElement: rootParent, querySelectorAll: () => [] };
|
const fixture = validCardForMutation();
|
||||||
const secondRoot = { parentElement: rootParent, querySelectorAll: () => [] };
|
|
||||||
let wrapper = { parentElement: firstRoot };
|
|
||||||
const page = {
|
const page = {
|
||||||
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
|
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
|
||||||
|
getComputedStyle: () => ({ display: "block", visibility: "visible" }),
|
||||||
document: {
|
document: {
|
||||||
|
documentElement,
|
||||||
querySelectorAll: (selector) => {
|
querySelectorAll: (selector) => {
|
||||||
assert.equal(selector, ".message-item-wrapper");
|
if (selector === ".contact-item-container.selected[data-cid]")
|
||||||
return [wrapper];
|
return [{ getAttribute: () => "conversation-1" }];
|
||||||
|
if (selector === ".session-rich-content.card") return [fixture.card];
|
||||||
|
assert.fail(`unexpected selector: ${selector}`);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
MutationObserver: class {
|
MutationObserver: class {
|
||||||
constructor(callback) {
|
constructor(callback) {
|
||||||
this.callback = callback;
|
this.callback = callback;
|
||||||
this.targets = [];
|
this.options = null;
|
||||||
observers.push(this);
|
observers.push(this);
|
||||||
}
|
}
|
||||||
|
observe(target, options) {
|
||||||
observe(target) {
|
assert.equal(target, documentElement);
|
||||||
this.targets.push(target);
|
this.options = options;
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect() {}
|
disconnect() {}
|
||||||
},
|
},
|
||||||
addEventListener: (type, listener) => listeners.set(type, listener),
|
addEventListener: (type, listener) => listeners.set(type, listener),
|
||||||
};
|
};
|
||||||
|
const observed = [];
|
||||||
|
installOneTalkRenderedCardObserver(page, (observations) => observed.push(...observations));
|
||||||
|
assert.equal(observed.length, 1);
|
||||||
|
|
||||||
installOneTalkRenderedCardObserver(page, () => undefined);
|
fixture.statusNode.textContent = "Updated status";
|
||||||
assert.ok(observers[0].targets.includes(firstRoot));
|
observers[0].callback([{ target: fixture.statusNode, addedNodes: [] }]);
|
||||||
|
|
||||||
wrapper = { parentElement: secondRoot };
|
assert.equal(observed.length, 2);
|
||||||
observers[0].callback([]);
|
assert.equal(observed[1].content.status, "Updated status");
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,71 @@
|
|||||||
// 验证 MAIN-world React Fiber reader 只投影订单、询盘与商品的批准字段。
|
// 验证 DOM-only reader 以可见结构采集命名字段,而不是以模板 ID 准入。
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { createOneTalkRenderedCardContentFingerprint } from "@trade-message-center/onetalk-contract";
|
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 node = ({
|
||||||
const productUrl = "https://chinese.alibaba.com/product-detail/widget-12345.html";
|
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] } = {}) => {
|
const pageFor = ({ conversationId = "conversation-1", selectionReads = [conversationId] } = {}) => {
|
||||||
let reads = 0;
|
let reads = 0;
|
||||||
@@ -18,351 +76,320 @@ const pageFor = ({ conversationId = "conversation-1", selectionReads = [conversa
|
|||||||
assert.equal(selector, ".contact-item-container.selected[data-cid]");
|
assert.equal(selector, ".contact-item-container.selected[data-cid]");
|
||||||
const selected = selectionReads[Math.min(reads, selectionReads.length - 1)];
|
const selected = selectionReads[Math.min(reads, selectionReads.length - 1)];
|
||||||
reads += 1;
|
reads += 1;
|
||||||
return selected
|
return selected ? [{ getAttribute: () => selected }] : [];
|
||||||
? [{ getAttribute: (name) => (name === "data-cid" ? selected : null) }]
|
|
||||||
: [];
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getComputedStyle: () => ({ display: "block", visibility: "visible" }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemFor = (overrides = {}) => ({
|
const cardFor = (
|
||||||
messageId: "message-1",
|
root,
|
||||||
conversationCode: "conversation-1",
|
{ cardInfo = { messageId: "message-1" }, wrapperInfo = { messageId: "message-1" } } = {},
|
||||||
messageType: "rec",
|
) => {
|
||||||
sendTime: 1_700_000_000_000,
|
const wrapper = { getAttribute: () => JSON.stringify(wrapperInfo) };
|
||||||
msgType: 10010,
|
const card = {
|
||||||
originalData: { cardType: 9 },
|
closest: (selector) => {
|
||||||
...overrides,
|
assert.equal(selector, ".message-item-wrapper[data-expinfo]");
|
||||||
});
|
return wrapper;
|
||||||
|
|
||||||
const wrapperFor = ({ item = itemFor(), data, cardType } = {}) => {
|
|
||||||
const templateFiber = {
|
|
||||||
memoizedProps: {
|
|
||||||
...(cardType === undefined ? {} : { cardType }),
|
|
||||||
...(data === undefined ? {} : { data }),
|
|
||||||
},
|
},
|
||||||
};
|
matches: (selector) => selector === "[data-expinfo]",
|
||||||
const fiber = { memoizedProps: { itemData: item }, child: templateFiber };
|
getAttribute: (name) => (name === "data-expinfo" ? JSON.stringify(cardInfo) : null),
|
||||||
return {
|
querySelector: (selector) => {
|
||||||
__reactFiber$fixture: fiber,
|
if (selector === "[data-expinfo]") return card;
|
||||||
querySelectorAll: (selector) => {
|
if (selector === ".tpl-wrapper") return root;
|
||||||
assert.equal(selector, "*");
|
assert.fail(`unexpected selector: ${selector}`);
|
||||||
return [];
|
|
||||||
},
|
},
|
||||||
|
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;
|
const previousNow = Date.now;
|
||||||
Date.now = () => 1_700_000_000_123;
|
Date.now = () => 1_700_000_000_123;
|
||||||
try {
|
try {
|
||||||
return readOneTalkRenderedCard(page, wrapper);
|
return readOneTalkRenderedCardFromDom(page, card);
|
||||||
} finally {
|
} finally {
|
||||||
Date.now = previousNow;
|
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 = {
|
const content = {
|
||||||
version: 1,
|
version: 1,
|
||||||
kind: "rendered_order",
|
image: "https://img.alicdn.com/order.jpg",
|
||||||
title: "Order #1",
|
productCount: { display: "3 items", value: 3 },
|
||||||
products: [{ title: "Widget", imageUrl }],
|
status: "Paid",
|
||||||
productCount: 1,
|
total: "US $10.00",
|
||||||
status: { code: "9", text: "Paid" },
|
deliveryDate: "2026-09-16",
|
||||||
payment: { totalDisplay: "$10", discountDisplay: "$1" },
|
shippingAddress: "1 Market Street",
|
||||||
delivery: { shippingAddress: "Hangzhou", methodLabel: "Express", dateLabel: "Tomorrow" },
|
|
||||||
action: { label: "View order", status: "available" },
|
|
||||||
};
|
};
|
||||||
const read = readWithFixedTime(
|
const result = readWithFixedTime(
|
||||||
pageFor(),
|
pageFor(),
|
||||||
wrapperFor({
|
cardFor(orderVariant({ templateId: "3829708538501" })),
|
||||||
data: {
|
|
||||||
cardTitle: content.title,
|
|
||||||
productInfoList: [{ productName: "Widget", productImage: imageUrl }],
|
|
||||||
productCount: 1,
|
|
||||||
orderStatus: 9,
|
|
||||||
orderStatusText: "Paid",
|
|
||||||
shouldPayAmount: "$10",
|
|
||||||
discountAmount: "$1",
|
|
||||||
shippingAddress: "Hangzhou",
|
|
||||||
shippingMethodText: "Express",
|
|
||||||
deliveryTimeText: "Tomorrow",
|
|
||||||
orderAction: { label: "View order", status: "available" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(result, {
|
||||||
assert.deepEqual(read, {
|
conversationId: "conversation-1",
|
||||||
observation: {
|
messageId: "message-1",
|
||||||
conversationId: "conversation-1",
|
content,
|
||||||
messageId: "message-1",
|
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
||||||
content,
|
observedAtMs: 1_700_000_000_123,
|
||||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
|
||||||
observedAtMs: 1_700_000_000_123,
|
|
||||||
},
|
|
||||||
baseEvidence: {
|
|
||||||
conversationId: "conversation-1",
|
|
||||||
messageId: "message-1",
|
|
||||||
direction: "received",
|
|
||||||
sentAtMs: 1_700_000_000_000,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reads an inquiry into only the approved product, requirement, reference, and ordered action fields", () => {
|
test("collects an order image beside a direct product-count display without list-item markup", () => {
|
||||||
const content = {
|
const root = node({
|
||||||
version: 1,
|
classNames: ["tpl-wrapper"],
|
||||||
kind: "rendered_inquiry",
|
children: [
|
||||||
product: { title: "Widget", imageUrl },
|
node({ text: "订单", viewName: "TextView" }),
|
||||||
purchaseQuantity: { value: "100", unit: "pieces" },
|
node({ imageUrl: "https://img.alicdn.com/order-chevron.jpg" }),
|
||||||
requirementText: "Need 100 widgets\nSecond line",
|
node({
|
||||||
inquiryReference: "inquiry-1",
|
children: [
|
||||||
actions: [
|
node({
|
||||||
{ label: "Details", available: true },
|
children: [
|
||||||
{ label: "Quote", available: false },
|
node({ imageUrl: "https://img.alicdn.com/order-product.jpg" }),
|
||||||
{ label: "Send inquiry", available: true },
|
node({ imageUrl: "https://img.alicdn.com/order-product-second.jpg" }),
|
||||||
],
|
],
|
||||||
};
|
}),
|
||||||
const read = readWithFixedTime(
|
node({
|
||||||
pageFor(),
|
children: [
|
||||||
wrapperFor({
|
node({
|
||||||
item: itemFor({ originalData: { cardType: 6 } }),
|
children: [
|
||||||
data: {
|
node({
|
||||||
inquiryCardDTO: {
|
text: "5 products",
|
||||||
summary: "Need 100 widgets\nSecond line",
|
viewName: "TextView",
|
||||||
unapproved: "must not cross",
|
}),
|
||||||
},
|
],
|
||||||
displayProducts: [
|
}),
|
||||||
{
|
],
|
||||||
productName: "Widget",
|
}),
|
||||||
productImage: imageUrl,
|
|
||||||
productMOQ: "100",
|
|
||||||
productUnit: "pieces",
|
|
||||||
hiddenProductField: "must not cross",
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
inquiryContent: "Need 100 widgets\nSecond line",
|
}),
|
||||||
inquiryID: "inquiry-1",
|
fieldRow("订单状态", "Pending"),
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, {
|
||||||
|
|
||||||
test("reads a product only through the product renderer and omits every raw or store-image field", () => {
|
|
||||||
const content = {
|
|
||||||
version: 1,
|
version: 1,
|
||||||
kind: "rendered_product",
|
image: "https://img.alicdn.com/order-product.jpg",
|
||||||
product: {
|
productCount: { display: "5 products", value: 5 },
|
||||||
title: "Widget",
|
status: "Pending",
|
||||||
imageUrl,
|
|
||||||
sourceUrl: productUrl,
|
|
||||||
productId: "12345",
|
|
||||||
},
|
|
||||||
priceDisplay: "$10",
|
|
||||||
minimumOrder: { value: "2", unit: "pieces" },
|
|
||||||
serviceBadges: ["Fast delivery", "Buyer protection"],
|
|
||||||
};
|
|
||||||
const read = readWithFixedTime(
|
|
||||||
pageFor(),
|
|
||||||
wrapperFor({
|
|
||||||
item: itemFor({ msgType: 101, originalData: {} }),
|
|
||||||
cardType: 54,
|
|
||||||
data: {
|
|
||||||
title: "Widget",
|
|
||||||
productImage: imageUrl,
|
|
||||||
price: "$10",
|
|
||||||
moq: "2",
|
|
||||||
moqUnit: "pieces",
|
|
||||||
productAction: { actionParams: { url: productUrl, unsafe: "must not cross" } },
|
|
||||||
benefitFirstText: "Fast delivery",
|
|
||||||
benefitSecondText: "Fast delivery",
|
|
||||||
promotionTag: "Buyer protection",
|
|
||||||
hsfImg: "https://img.alicdn.com/store.jpg",
|
|
||||||
storeImageUrl: "https://img.alicdn.com/store.jpg",
|
|
||||||
traceInfo: { unsafe: "must not cross" },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
assert.deepEqual(read, {
|
|
||||||
observation: {
|
|
||||||
conversationId: "conversation-1",
|
|
||||||
messageId: "message-1",
|
|
||||||
content,
|
|
||||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
|
||||||
observedAtMs: 1_700_000_000_123,
|
|
||||||
},
|
|
||||||
baseEvidence: {
|
|
||||||
conversationId: "conversation-1",
|
|
||||||
messageId: "message-1",
|
|
||||||
direction: "received",
|
|
||||||
sentAtMs: 1_700_000_000_000,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
assert.deepEqual(Object.keys(read?.observation.content.product ?? {}).sort(), [
|
|
||||||
"imageUrl",
|
|
||||||
"productId",
|
|
||||||
"sourceUrl",
|
|
||||||
"title",
|
|
||||||
]);
|
|
||||||
for (const forbidden of ["storeImageUrl", "hsfImg", "traceInfo", "actionParams", "data"]) {
|
|
||||||
assert.equal(forbidden in (read?.observation.content.product ?? {}), false);
|
|
||||||
assert.equal(forbidden in (read?.observation.content ?? {}), false);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("rejects files, lookalikes, incomplete templates and identity or session changes", () => {
|
test("omits unrecognized inquiry rows instead of emitting attributes", () => {
|
||||||
const orderData = {
|
const root = node({
|
||||||
cardTitle: "Order #1",
|
classNames: ["tpl-wrapper"],
|
||||||
productInfoList: [{ productName: "Widget", productImage: imageUrl }],
|
children: [
|
||||||
orderStatusText: "Paid",
|
fieldRow("Colour", "Blue"),
|
||||||
shouldPayAmount: "$10",
|
node({ text: "详细需求", viewName: "TextView" }),
|
||||||
shippingAddress: "Hangzhou",
|
node({ classNames: ["description-container"], text: "Need delivery this week" }),
|
||||||
};
|
node({ classNames: ["dx-event-node"], text: "View details" }),
|
||||||
const cases = [
|
],
|
||||||
{
|
});
|
||||||
name: "file card",
|
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, {
|
||||||
page: pageFor(),
|
version: 1,
|
||||||
wrapper: wrapperFor({
|
requirementText: "Need delivery this week",
|
||||||
item: itemFor({ originalData: { cardType: 12 } }),
|
});
|
||||||
data: orderData,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unknown 10010 card",
|
|
||||||
page: pageFor(),
|
|
||||||
wrapper: wrapperFor({
|
|
||||||
item: itemFor({ originalData: { cardType: 7 } }),
|
|
||||||
data: orderData,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "non-product 101 card",
|
|
||||||
page: pageFor(),
|
|
||||||
wrapper: wrapperFor({
|
|
||||||
item: itemFor({ msgType: 101, originalData: {} }),
|
|
||||||
data: orderData,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "missing template field",
|
|
||||||
page: pageFor(),
|
|
||||||
wrapper: wrapperFor({ data: { ...orderData, shippingAddress: undefined } }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsafe image URL",
|
|
||||||
page: pageFor(),
|
|
||||||
wrapper: wrapperFor({
|
|
||||||
data: {
|
|
||||||
...orderData,
|
|
||||||
productInfoList: [
|
|
||||||
{ productName: "Widget", productImage: "https://evil.example/image.jpg" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "unsafe product reference",
|
|
||||||
page: pageFor(),
|
|
||||||
wrapper: wrapperFor({
|
|
||||||
item: itemFor({ msgType: 101, originalData: {} }),
|
|
||||||
cardType: 54,
|
|
||||||
data: {
|
|
||||||
title: "Widget",
|
|
||||||
productImage: imageUrl,
|
|
||||||
price: "$10",
|
|
||||||
moq: "2",
|
|
||||||
moqUnit: "pieces",
|
|
||||||
productAction: {
|
|
||||||
actionParams: { url: "https://evil.example/widget-12345.html" },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "incomplete identity",
|
|
||||||
page: pageFor(),
|
|
||||||
wrapper: wrapperFor({ item: itemFor({ messageId: "" }), data: orderData }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "selection changes while reading",
|
|
||||||
page: pageFor({ selectionReads: ["conversation-1", "conversation-2"] }),
|
|
||||||
wrapper: wrapperFor({ data: orderData }),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const entry of cases) {
|
|
||||||
assert.equal(readWithFixedTime(entry.page, entry.wrapper), null, entry.name);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("does not classify a product renderer beyond the bounded Fiber subtree scan", () => {
|
test("collects an inquiry product, purchase quantity, and requirement without its UI ID", () => {
|
||||||
const data = {
|
const root = node({
|
||||||
title: "Widget",
|
classNames: ["tpl-wrapper"],
|
||||||
productImage: imageUrl,
|
children: [
|
||||||
price: "$10",
|
node({
|
||||||
moq: "2",
|
classNames: ["list-item"],
|
||||||
moqUnit: "pieces",
|
children: [
|
||||||
productAction: { actionParams: { url: productUrl } },
|
node({ imageUrl: "https://img.alicdn.com/inquiry-product.jpg" }),
|
||||||
};
|
node({ text: "Inquiry product title", viewName: "TextView" }),
|
||||||
const fiber = {
|
],
|
||||||
memoizedProps: { itemData: itemFor({ msgType: 101, originalData: {} }), data },
|
}),
|
||||||
};
|
fieldRow("采购数量", "500 Piece/Pieces"),
|
||||||
let cursor = fiber;
|
node({ text: "详细需求", viewName: "TextView" }),
|
||||||
for (let index = 0; index < 120; index += 1) {
|
node({
|
||||||
cursor.sibling = { memoizedProps: {} };
|
classNames: ["description-container"],
|
||||||
cursor = cursor.sibling;
|
text: "Need custom sizing\nSecond line",
|
||||||
}
|
}),
|
||||||
cursor.memoizedProps.cardType = 54;
|
fieldRow("ID:", "13894257698"),
|
||||||
const wrapper = {
|
],
|
||||||
__reactFiber$fixture: fiber,
|
});
|
||||||
querySelectorAll: () => [],
|
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, {
|
||||||
};
|
version: 1,
|
||||||
|
title: "Inquiry product title",
|
||||||
assert.equal(readWithFixedTime(pageFor(), wrapper), null);
|
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", () => {
|
test("preserves rendered line breaks in an inquiry requirement", () => {
|
||||||
const productData = {
|
const requirement = node({
|
||||||
title: "Widget",
|
classNames: ["description-container"],
|
||||||
productImage: imageUrl,
|
text: "Need custom sizingSecond line",
|
||||||
price: "$10",
|
});
|
||||||
moq: "2",
|
requirement.innerText = "Need custom sizing\nSecond line";
|
||||||
moqUnit: "pieces",
|
const root = node({
|
||||||
productAction: { actionParams: { url: productUrl } },
|
classNames: ["tpl-wrapper"],
|
||||||
};
|
children: [node({ text: "详细需求", viewName: "TextView" }), requirement],
|
||||||
const rootFiber = {
|
});
|
||||||
memoizedProps: { itemData: itemFor({ msgType: 101, originalData: {} }) },
|
assert.equal(
|
||||||
sibling: { memoizedProps: { cardType: 54, data: productData } },
|
readWithFixedTime(pageFor(), cardFor(root))?.content.requirementText,
|
||||||
};
|
"Need custom sizing\nSecond line",
|
||||||
const wrapper = {
|
);
|
||||||
__reactFiber$fixture: rootFiber,
|
});
|
||||||
querySelectorAll: () => [],
|
|
||||||
};
|
test("rejects an inquiry-shaped card without the detailed-requirement anchor", () => {
|
||||||
|
const root = node({
|
||||||
assert.equal(readWithFixedTime(pageFor(), wrapper), null);
|
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,
|
content,
|
||||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
||||||
observedAtMs: 100,
|
observedAtMs: 100,
|
||||||
baseDirection: "received",
|
|
||||||
baseSentAtMs: 99,
|
|
||||||
status: "pending_ack",
|
status: "pending_ack",
|
||||||
firstObservedAt: 100,
|
firstObservedAt: 100,
|
||||||
updatedAt: 100,
|
updatedAt: 100,
|
||||||
@@ -506,7 +504,6 @@ test("clears only one conversation history ledger after its transaction commits"
|
|||||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(renderedCardContent),
|
contentFingerprint: createOneTalkRenderedCardContentFingerprint(renderedCardContent),
|
||||||
observedAtMs: 500,
|
observedAtMs: 500,
|
||||||
},
|
},
|
||||||
baseEvidence: { direction: "received", sentAtMs: 499 },
|
|
||||||
observedAt: 500,
|
observedAt: 500,
|
||||||
});
|
});
|
||||||
for (const [channelAccountId, conversationId, messageId] of [
|
for (const [channelAccountId, conversationId, messageId] of [
|
||||||
@@ -523,7 +520,6 @@ test("clears only one conversation history ledger after its transaction commits"
|
|||||||
createOneTalkRenderedCardContentFingerprint(renderedCardContent),
|
createOneTalkRenderedCardContentFingerprint(renderedCardContent),
|
||||||
observedAtMs: 500,
|
observedAtMs: 500,
|
||||||
},
|
},
|
||||||
baseEvidence: { direction: "received", sentAtMs: 499 },
|
|
||||||
observedAt: 500,
|
observedAt: 500,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -643,20 +639,19 @@ test("preserves the first rendered-card snapshot across concurrent same-key obse
|
|||||||
store.observe({
|
store.observe({
|
||||||
channelAccountId: "account-1",
|
channelAccountId: "account-1",
|
||||||
observation: first,
|
observation: first,
|
||||||
baseEvidence: { direction: "received", sentAtMs: 499 },
|
|
||||||
observedAt: 500,
|
observedAt: 500,
|
||||||
}),
|
}),
|
||||||
store.observe({
|
store.observe({
|
||||||
channelAccountId: "account-1",
|
channelAccountId: "account-1",
|
||||||
observation: second,
|
observation: second,
|
||||||
baseEvidence: { direction: "received", sentAtMs: 499 },
|
|
||||||
observedAt: 501,
|
observedAt: 501,
|
||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const record = await store.get("account-1", "conversation-1", "message-1");
|
const record = await store.get("account-1", "conversation-1", "message-1");
|
||||||
assert.deepEqual(record.content, firstContent);
|
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 () => {
|
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,
|
observedAtMs: 500,
|
||||||
};
|
};
|
||||||
const second = { ...first, observedAtMs: 501 };
|
const second = { ...first, observedAtMs: 501 };
|
||||||
const baseEvidence = { direction: "received", sentAtMs: 499 };
|
await store.observe({ channelAccountId: "account-1", observation: first });
|
||||||
await store.observe({ channelAccountId: "account-1", observation: first, baseEvidence });
|
|
||||||
await store.markSent({
|
await store.markSent({
|
||||||
channelAccountId: "account-1",
|
channelAccountId: "account-1",
|
||||||
conversationId: first.conversationId,
|
conversationId: first.conversationId,
|
||||||
@@ -692,7 +686,7 @@ test("keeps the first pending ACK correlation while serializing same-key ledger
|
|||||||
requestId: "request-1",
|
requestId: "request-1",
|
||||||
});
|
});
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
store.observe({ channelAccountId: "account-1", observation: second, baseEvidence }),
|
store.observe({ channelAccountId: "account-1", observation: second }),
|
||||||
store.markAcknowledged({
|
store.markAcknowledged({
|
||||||
channelAccountId: "account-1",
|
channelAccountId: "account-1",
|
||||||
conversationId: first.conversationId,
|
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,
|
"when": 1789399393296,
|
||||||
"tag": "0014_youthful_iron_lad",
|
"tag": "0014_youthful_iron_lad",
|
||||||
"breakpoints": true
|
"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(
|
export const onetalkRenderedCardContent = pgTable(
|
||||||
"onetalk_rendered_card_content",
|
"onetalk_rendered_card_content",
|
||||||
{
|
{
|
||||||
@@ -144,20 +144,8 @@ export const onetalkRenderedCardContent = pgTable(
|
|||||||
renderedCardObservedAtMs: bigint("rendered_card_observed_at_ms", {
|
renderedCardObservedAtMs: bigint("rendered_card_observed_at_ms", {
|
||||||
mode: "number",
|
mode: "number",
|
||||||
}).notNull(),
|
}).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),
|
conflictCount: integer("conflict_count").notNull().default(0),
|
||||||
/** 最近一次冲突补全的指纹,仅用于诊断。 */
|
|
||||||
lastConflictingFingerprint: text("last_conflicting_fingerprint"),
|
|
||||||
/** 最近一次冲突 observation 的页面时间。 */
|
|
||||||
lastConflictObservedAtMs: bigint("last_conflict_observed_at_ms", { mode: "number" }),
|
|
||||||
},
|
},
|
||||||
(table) => [
|
(table) => [
|
||||||
primaryKey({ columns: [table.channelAccountId, table.conversationId, table.messageId] }),
|
primaryKey({ columns: [table.channelAccountId, table.conversationId, table.messageId] }),
|
||||||
@@ -168,7 +156,7 @@ export const onetalkRenderedCardContent = pgTable(
|
|||||||
),
|
),
|
||||||
check(
|
check(
|
||||||
"onetalk_rendered_card_content_v1_chk",
|
"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,
|
isOneTalkCenterMessageContent,
|
||||||
isOneTalkMessageContent,
|
isOneTalkMessageContent,
|
||||||
isOneTalkRenderedCardContent,
|
isOneTalkRenderedCardContent,
|
||||||
|
isOneTalkRenderedCard,
|
||||||
type OneTalkHttpConversation,
|
type OneTalkHttpConversation,
|
||||||
type OneTalkCenterMessage,
|
type OneTalkCenterMessage,
|
||||||
type OneTalkMessage,
|
type OneTalkMessage,
|
||||||
type OneTalkCenterMessageContent,
|
type OneTalkCenterMessageContent,
|
||||||
type OneTalkRenderedCardContent,
|
type OneTalkRenderedCardContent,
|
||||||
|
type OneTalkRenderedCard,
|
||||||
} from "@trade-message-center/onetalk-contract";
|
} from "@trade-message-center/onetalk-contract";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
@@ -108,6 +110,7 @@ const supplementMessageContent = (
|
|||||||
if (!isOneTalkRenderedCardContent(renderedCardContent)) {
|
if (!isOneTalkRenderedCardContent(renderedCardContent)) {
|
||||||
throw new Error("Invalid persisted OneTalk rendered-card supplement");
|
throw new Error("Invalid persisted OneTalk rendered-card supplement");
|
||||||
}
|
}
|
||||||
|
if (isOneTalkRenderedCard(renderedCardContent)) return content;
|
||||||
if (renderedCardContent.kind === "rendered_inquiry") {
|
if (renderedCardContent.kind === "rendered_inquiry") {
|
||||||
if (content.kind !== "inquiry") {
|
if (content.kind !== "inquiry") {
|
||||||
throw new Error("OneTalk rendered-card supplement kind does not match base message");
|
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 = (
|
const projectMessageContent = (
|
||||||
content: OneTalkCenterMessageContent,
|
content: OneTalkCenterMessageContent,
|
||||||
customerProfile?: Pick<
|
customerProfile?: Pick<
|
||||||
@@ -184,6 +198,7 @@ export const toOneTalkCenterMessage = (
|
|||||||
>,
|
>,
|
||||||
): OneTalkCenterMessage => {
|
): OneTalkCenterMessage => {
|
||||||
const content = supplementMessageContent(message.content, renderedCardContent);
|
const content = supplementMessageContent(message.content, renderedCardContent);
|
||||||
|
const renderedCard = renderedCardFor(renderedCardContent);
|
||||||
if (!isOneTalkCenterMessageContent(content)) {
|
if (!isOneTalkCenterMessageContent(content)) {
|
||||||
throw new Error("Invalid persisted OneTalk message content");
|
throw new Error("Invalid persisted OneTalk message content");
|
||||||
}
|
}
|
||||||
@@ -195,6 +210,7 @@ export const toOneTalkCenterMessage = (
|
|||||||
direction: message.direction,
|
direction: message.direction,
|
||||||
sentAtMs: message.sentAtMs,
|
sentAtMs: message.sentAtMs,
|
||||||
content: projectMessageContent(content, customerProfile),
|
content: projectMessageContent(content, customerProfile),
|
||||||
|
...(renderedCard === undefined ? {} : { renderedCard }),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ export type OneTalkRenderedCardRepository = {
|
|||||||
store: (input: {
|
store: (input: {
|
||||||
channelAccountId: string;
|
channelAccountId: string;
|
||||||
observation: OneTalkRenderedCardObservation;
|
observation: OneTalkRenderedCardObservation;
|
||||||
receivedAt: Date;
|
|
||||||
commitGuard?: OneTalkCommitGuard;
|
commitGuard?: OneTalkCommitGuard;
|
||||||
}) => Promise<OneTalkRenderedCardStoreResult>;
|
}) => Promise<OneTalkRenderedCardStoreResult>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -79,8 +79,6 @@ const store = async (
|
|||||||
renderedCardContent: input.observation.content,
|
renderedCardContent: input.observation.content,
|
||||||
renderedCardContentFingerprint: input.observation.contentFingerprint,
|
renderedCardContentFingerprint: input.observation.contentFingerprint,
|
||||||
renderedCardObservedAtMs: input.observation.observedAtMs,
|
renderedCardObservedAtMs: input.observation.observedAtMs,
|
||||||
firstConfirmedAt: input.receivedAt,
|
|
||||||
lastObservedAt: input.receivedAt,
|
|
||||||
});
|
});
|
||||||
input.commitGuard?.assertValid();
|
input.commitGuard?.assertValid();
|
||||||
const baseMessage = await existingBaseMessageFor(transaction, input);
|
const baseMessage = await existingBaseMessageFor(transaction, input);
|
||||||
@@ -93,10 +91,6 @@ const store = async (
|
|||||||
}
|
}
|
||||||
const saved = record.renderedCardContent as OneTalkRenderedCardContent;
|
const saved = record.renderedCardContent as OneTalkRenderedCardContent;
|
||||||
if (isSameOneTalkRenderedCardContent(saved, input.observation.content)) {
|
if (isSameOneTalkRenderedCardContent(saved, input.observation.content)) {
|
||||||
await transaction
|
|
||||||
.update(onetalkRenderedCardContent)
|
|
||||||
.set({ lastObservedAt: input.receivedAt })
|
|
||||||
.where(keyCondition(input));
|
|
||||||
input.commitGuard?.assertValid();
|
input.commitGuard?.assertValid();
|
||||||
const baseMessage = await existingBaseMessageFor(transaction, input);
|
const baseMessage = await existingBaseMessageFor(transaction, input);
|
||||||
input.commitGuard?.assertValid();
|
input.commitGuard?.assertValid();
|
||||||
@@ -108,11 +102,7 @@ const store = async (
|
|||||||
}
|
}
|
||||||
await transaction
|
await transaction
|
||||||
.update(onetalkRenderedCardContent)
|
.update(onetalkRenderedCardContent)
|
||||||
.set({
|
.set({ conflictCount: sql`${onetalkRenderedCardContent.conflictCount} + 1` })
|
||||||
conflictCount: sql`${onetalkRenderedCardContent.conflictCount} + 1`,
|
|
||||||
lastConflictingFingerprint: input.observation.contentFingerprint,
|
|
||||||
lastConflictObservedAtMs: input.observation.observedAtMs,
|
|
||||||
})
|
|
||||||
.where(keyCondition(input));
|
.where(keyCondition(input));
|
||||||
input.commitGuard?.assertValid();
|
input.commitGuard?.assertValid();
|
||||||
const baseMessage = await existingBaseMessageFor(transaction, input);
|
const baseMessage = await existingBaseMessageFor(transaction, input);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// 编排 OneTalk 渲染卡片补全的受控写入
|
// 编排 OneTalk 渲染卡片补全的受控写入
|
||||||
|
|
||||||
import {
|
import {
|
||||||
cloneOneTalkRenderedCardContent,
|
cloneOneTalkRenderedCard,
|
||||||
createOneTalkRenderedCardContentFingerprint,
|
createOneTalkRenderedCardContentFingerprint,
|
||||||
} from "@trade-message-center/onetalk-contract";
|
} from "@trade-message-center/onetalk-contract";
|
||||||
import type {
|
import type {
|
||||||
@@ -12,10 +12,9 @@ import type {
|
|||||||
/** 创建仅提交独立 supplement 事实的服务。 */
|
/** 创建仅提交独立 supplement 事实的服务。 */
|
||||||
export const createOneTalkRenderedCardService = (
|
export const createOneTalkRenderedCardService = (
|
||||||
repository: OneTalkRenderedCardRepository,
|
repository: OneTalkRenderedCardRepository,
|
||||||
now: () => Date = () => new Date(),
|
|
||||||
): OneTalkRenderedCardService => ({
|
): OneTalkRenderedCardService => ({
|
||||||
ingest: async (input) => {
|
ingest: async (input) => {
|
||||||
const content = cloneOneTalkRenderedCardContent(input.observation.content);
|
const content = cloneOneTalkRenderedCard(input.observation.content);
|
||||||
return repository.store({
|
return repository.store({
|
||||||
channelAccountId: input.channelAccountId,
|
channelAccountId: input.channelAccountId,
|
||||||
observation: {
|
observation: {
|
||||||
@@ -23,7 +22,6 @@ export const createOneTalkRenderedCardService = (
|
|||||||
content,
|
content,
|
||||||
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
||||||
},
|
},
|
||||||
receivedAt: now(),
|
|
||||||
commitGuard: input.commitGuard,
|
commitGuard: input.commitGuard,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
// 验证 OneTalk 名片资料从消息 JSONB 中移除的迁移边界
|
// 验证 OneTalk 名片资料从消息 JSONB 中移除的迁移边界
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { fileURLToPath } from "node:url";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { migrationDirectory } from "../src/database/migration-config.ts";
|
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 () => {
|
test("normalizes every persisted business-card payload before enforcing the marker-only check", async () => {
|
||||||
const sql = await readFile(migrationPath, "utf8");
|
const sql = await readFile(migrationPath, "utf8");
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
// 验证 OneTalk 图片宽高数据迁移的结构边界
|
// 验证 OneTalk 图片宽高数据迁移的结构边界
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { fileURLToPath } from "node:url";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { migrationDirectory } from "../src/database/migration-config.ts";
|
import { migrationDirectory } from "../src/database/migration-config.ts";
|
||||||
|
|
||||||
const migrationPath = resolve(
|
const migrationPathUrl = new URL(
|
||||||
process.cwd(),
|
`../${migrationDirectory}/0009_remove_image_dimensions.sql`,
|
||||||
migrationDirectory,
|
import.meta.url,
|
||||||
"0009_remove_image_dimensions.sql",
|
);
|
||||||
|
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 () => {
|
test("keeps the image-dimensions migration as one breakpoint-delimited data statement", async () => {
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
// 验证 OneTalk 商品内容只扩展 JSONB kind 约束,不重写既有事实。
|
// 验证 OneTalk 商品内容只扩展 JSONB kind 约束,不重写既有事实。
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
import { readFile } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { fileURLToPath } from "node:url";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { migrationDirectory } from "../src/database/migration-config.ts";
|
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 () => {
|
test("extends only the content-kind check with product while preserving existing constraints", async () => {
|
||||||
const sql = await readFile(migrationPath, "utf8");
|
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", () => {
|
test("rejects a rendered-card supplement with a different base kind or product identity", () => {
|
||||||
const product = {
|
const product = {
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
|
|||||||
@@ -20,14 +20,9 @@ const context = {
|
|||||||
};
|
};
|
||||||
const content = {
|
const content = {
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
kind: "rendered_order" as const,
|
status: "Paid",
|
||||||
title: "Order",
|
total: "US $10.00",
|
||||||
products: [],
|
image: "https://img.alicdn.com/card.jpg",
|
||||||
productCount: 0,
|
|
||||||
status: { code: null, text: "Paid" },
|
|
||||||
payment: { totalDisplay: "$1", discountDisplay: null },
|
|
||||||
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
|
|
||||||
action: { label: null, status: null },
|
|
||||||
};
|
};
|
||||||
const frame = createOneTalkRenderedCardObservedFrame(
|
const frame = createOneTalkRenderedCardObservedFrame(
|
||||||
{ connectionType: "plugin", requestId: "request-1", scope },
|
{ connectionType: "plugin", requestId: "request-1", scope },
|
||||||
@@ -171,16 +166,8 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) {
|
|||||||
participantIds: message.participantIds,
|
participantIds: message.participantIds,
|
||||||
direction: message.direction,
|
direction: message.direction,
|
||||||
sentAtMs: message.sentAtMs,
|
sentAtMs: message.sentAtMs,
|
||||||
content: {
|
content: message.content,
|
||||||
...message.content,
|
renderedCard: content,
|
||||||
title: content.title,
|
|
||||||
products: content.products,
|
|
||||||
productCount: content.productCount,
|
|
||||||
status: content.status,
|
|
||||||
payment: content.payment,
|
|
||||||
delivery: content.delivery,
|
|
||||||
action: content.action,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
requestId: "request-1",
|
requestId: "request-1",
|
||||||
scope: {
|
scope: {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
// 验证渲染卡片补全使用独立、带备注的追加迁移。
|
// 验证渲染卡片补全使用独立、带备注的追加迁移。
|
||||||
|
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readdir, readFile } from "node:fs/promises";
|
import { existsSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { readFile } from "node:fs/promises";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
|
|
||||||
import { ONETALK_INITIAL_HISTORY_GENERATION } from "@trade-message-center/onetalk-contract";
|
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);
|
assert.equal(ONETALK_SCHEMA_INITIAL_HISTORY_GENERATION, ONETALK_INITIAL_HISTORY_GENERATION);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("adds the rendered-card fact table in the latest additive migration", async () => {
|
test("drops obsolete audit columns and expands the rendered-card kinds without rewriting history", async () => {
|
||||||
const directory = resolve(process.cwd(), migrationDirectory);
|
const sourceMigrationUrl = new URL(`../${migrationDirectory}/`, import.meta.url);
|
||||||
const files = (await readdir(directory)).filter((file) => /^\d+_.+\.sql$/u.test(file)).sort();
|
const migrationUrl = existsSync(sourceMigrationUrl)
|
||||||
const latest = files.at(-1);
|
? sourceMigrationUrl
|
||||||
assert.ok(latest, "expected at least one SQL migration");
|
: new URL(`../../${migrationDirectory}/`, import.meta.url);
|
||||||
const sql = await readFile(resolve(directory, latest), "utf8");
|
const migration015 = await readFile(new URL("0015_wild_toad_men.sql", migrationUrl), "utf8");
|
||||||
|
const migration016 = await readFile(
|
||||||
assert.match(sql, /CREATE TABLE "onetalk_rendered_card_content"/i);
|
new URL("0016_optimal_imperial_guard.sql", migrationUrl),
|
||||||
assert.match(
|
"utf8",
|
||||||
sql,
|
|
||||||
/PRIMARY KEY\s*\("channel_account_id",\s*"conversation_id",\s*"message_id"\)/i,
|
|
||||||
);
|
);
|
||||||
assert.match(sql, /COMMENT ON TABLE "onetalk_rendered_card_content"/i);
|
|
||||||
for (const column of [
|
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",
|
"first_confirmed_at",
|
||||||
"last_observed_at",
|
"last_observed_at",
|
||||||
"conflict_count",
|
|
||||||
"last_conflicting_fingerprint",
|
"last_conflicting_fingerprint",
|
||||||
"last_conflict_observed_at_ms",
|
"last_conflict_observed_at_ms",
|
||||||
]) {
|
])
|
||||||
assert.match(
|
assert.match(
|
||||||
sql,
|
migration015,
|
||||||
new RegExp(`COMMENT ON COLUMN "onetalk_rendered_card_content"."${column}"`, "i"),
|
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 = {
|
const content = {
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
kind: "rendered_order" as const,
|
status: "Paid",
|
||||||
title: "Order",
|
total: "US $10.00",
|
||||||
products: [],
|
image: "https://img.alicdn.com/card.jpg",
|
||||||
productCount: 0,
|
|
||||||
status: { code: null, text: "Paid" },
|
|
||||||
payment: { totalDisplay: "$1", discountDisplay: null },
|
|
||||||
delivery: { shippingAddress: "Hangzhou", methodLabel: null, dateLabel: null },
|
|
||||||
action: { label: null, status: null },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test("stores a rendered-card supplement when its base message is absent", async () => {
|
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),
|
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
||||||
observedAtMs: 100,
|
observedAtMs: 100,
|
||||||
},
|
},
|
||||||
receivedAt: new Date("2026-09-15T00:00:00.000Z"),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.deepEqual(result, { status: "accepted", renderedCardContent: content, message: null });
|
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,
|
renderedCardContent: content,
|
||||||
renderedCardContentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
renderedCardContentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
|
||||||
renderedCardObservedAtMs: 100,
|
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 = {
|
const renderedCardContent = {
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
kind: "rendered_order" as const,
|
status: "Paid",
|
||||||
title: "Matrix order",
|
total: "$1",
|
||||||
products: [],
|
shippingAddress: "Matrix address",
|
||||||
productCount: 0,
|
productCount: { display: "0 items", value: 0 },
|
||||||
status: { code: null, text: "Paid" },
|
|
||||||
payment: { totalDisplay: "$1", discountDisplay: null },
|
|
||||||
delivery: { shippingAddress: "Matrix address", methodLabel: null, dateLabel: null },
|
|
||||||
action: { label: null, status: null },
|
|
||||||
};
|
};
|
||||||
const renderedCardAck = nextMessage(plugin);
|
const renderedCardAck = nextMessage(plugin);
|
||||||
const renderedCardUpdate = nextFrame(mind, (frame) => frame.type === "message.updated");
|
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, {
|
assert.deepEqual(renderedCardFrames[1]?.payload, {
|
||||||
message: {
|
message: {
|
||||||
...centerMessage(orderMessage("matrix-rendered-card-message")),
|
...centerMessage(orderMessage("matrix-rendered-card-message")),
|
||||||
content: {
|
renderedCard: renderedCardContent,
|
||||||
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 },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(renderedCardInputs.length, 1);
|
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,
|
isOneTalkRenderedCardPayment,
|
||||||
isOneTalkRenderedCardQuantity,
|
isOneTalkRenderedCardQuantity,
|
||||||
isOneTalkRenderedCardText,
|
isOneTalkRenderedCardText,
|
||||||
|
isOneTalkRenderedCard,
|
||||||
type OneTalkRenderedCardAction,
|
type OneTalkRenderedCardAction,
|
||||||
type OneTalkRenderedCardDelivery,
|
type OneTalkRenderedCardDelivery,
|
||||||
type OneTalkRenderedCardImage,
|
type OneTalkRenderedCardImage,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
type OneTalkRenderedCardOrderStatus,
|
type OneTalkRenderedCardOrderStatus,
|
||||||
type OneTalkRenderedCardPayment,
|
type OneTalkRenderedCardPayment,
|
||||||
type OneTalkRenderedCardQuantity,
|
type OneTalkRenderedCardQuantity,
|
||||||
|
type OneTalkRenderedCard,
|
||||||
} from "./rendered-cards.ts";
|
} from "./rendered-cards.ts";
|
||||||
|
|
||||||
export const ONETALK_CONTENT_VERSION = 1 as const;
|
export const ONETALK_CONTENT_VERSION = 1 as const;
|
||||||
@@ -188,6 +190,8 @@ export type OneTalkCenterMessage = {
|
|||||||
direction: OneTalkDirection;
|
direction: OneTalkDirection;
|
||||||
sentAtMs: number;
|
sentAtMs: number;
|
||||||
content: OneTalkCenterMessageContent;
|
content: OneTalkCenterMessageContent;
|
||||||
|
/** 与基础消息同一复合 identity 的 DOM-only 公共快照。 */
|
||||||
|
renderedCard?: OneTalkRenderedCard;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OneTalkMessageContentDecodeResult =
|
export type OneTalkMessageContentDecodeResult =
|
||||||
@@ -262,6 +266,8 @@ const CENTER_MESSAGE_KEYS = [
|
|||||||
"content",
|
"content",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
const CENTER_MESSAGE_RENDERED_CARD_KEYS = [...CENTER_MESSAGE_KEYS, "renderedCard"] as const;
|
||||||
|
|
||||||
const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm";
|
const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm";
|
||||||
const THUMBNAIL_URL_PATH = "/file/videoThumb.htm";
|
const THUMBNAIL_URL_PATH = "/file/videoThumb.htm";
|
||||||
const ALLOWED_MEDIA_QUERY_KEYS = [
|
const ALLOWED_MEDIA_QUERY_KEYS = [
|
||||||
@@ -712,7 +718,12 @@ export const isCenterConversation = (value: unknown): value is CenterConversatio
|
|||||||
|
|
||||||
/** 判断值是否为 exact-shape 的 Mind 公开消息读取模型。 */
|
/** 判断值是否为 exact-shape 的 Mind 公开消息读取模型。 */
|
||||||
export const isOneTalkCenterMessage = (value: unknown): value is OneTalkCenterMessage => {
|
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 (
|
return (
|
||||||
isNonBlankString(value.messageId, MAX_IDENTIFIER_LENGTH) &&
|
isNonBlankString(value.messageId, MAX_IDENTIFIER_LENGTH) &&
|
||||||
isNonBlankString(value.conversationId, 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" &&
|
typeof value.direction === "string" &&
|
||||||
ONETALK_DIRECTIONS.includes(value.direction as OneTalkDirection) &&
|
ONETALK_DIRECTIONS.includes(value.direction as OneTalkDirection) &&
|
||||||
isNonNegativeSafeInteger(value.sentAtMs) &&
|
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_CONTENT_VERSION,
|
||||||
ONETALK_RENDERED_CARD_ACK_STATUSES,
|
ONETALK_RENDERED_CARD_ACK_STATUSES,
|
||||||
cloneOneTalkRenderedCardContent,
|
cloneOneTalkRenderedCardContent,
|
||||||
|
cloneOneTalkRenderedCard,
|
||||||
createOneTalkRenderedCardAckFrame,
|
createOneTalkRenderedCardAckFrame,
|
||||||
createOneTalkRenderedCardContentFingerprint,
|
createOneTalkRenderedCardContentFingerprint,
|
||||||
createOneTalkRenderedCardObservedFrame,
|
createOneTalkRenderedCardObservedFrame,
|
||||||
isOneTalkRenderedCardContent,
|
isOneTalkRenderedCardContent,
|
||||||
|
isOneTalkRenderedCard,
|
||||||
isOneTalkRenderedCardImage,
|
isOneTalkRenderedCardImage,
|
||||||
isOneTalkRenderedCardImageUrl,
|
isOneTalkRenderedCardImageUrl,
|
||||||
isOneTalkRenderedCardIdentifier,
|
isOneTalkRenderedCardIdentifier,
|
||||||
@@ -154,6 +156,7 @@ export type {
|
|||||||
OneTalkRenderedCardAction,
|
OneTalkRenderedCardAction,
|
||||||
OneTalkRenderedCardAckFrame,
|
OneTalkRenderedCardAckFrame,
|
||||||
OneTalkRenderedCardContent,
|
OneTalkRenderedCardContent,
|
||||||
|
OneTalkRenderedCard,
|
||||||
OneTalkRenderedCardImage,
|
OneTalkRenderedCardImage,
|
||||||
OneTalkRenderedCardOrderStatus,
|
OneTalkRenderedCardOrderStatus,
|
||||||
OneTalkRenderedCardPayment,
|
OneTalkRenderedCardPayment,
|
||||||
|
|||||||
@@ -54,15 +54,31 @@ export type OneTalkRenderedOrderContent = {
|
|||||||
delivery: OneTalkRenderedCardDelivery;
|
delivery: OneTalkRenderedCardDelivery;
|
||||||
action: OneTalkRenderedCardOrderAction;
|
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 =
|
export type OneTalkRenderedCardContent =
|
||||||
| OneTalkRenderedInquiryContent
|
| OneTalkRenderedInquiryContent
|
||||||
| OneTalkRenderedProductContent
|
| OneTalkRenderedProductContent
|
||||||
| OneTalkRenderedOrderContent;
|
| OneTalkRenderedOrderContent
|
||||||
|
| OneTalkRenderedCard;
|
||||||
|
|
||||||
export type OneTalkRenderedCardObservation = {
|
export type OneTalkRenderedCardObservation = {
|
||||||
conversationId: string;
|
conversationId: string;
|
||||||
messageId: string;
|
messageId: string;
|
||||||
content: OneTalkRenderedCardContent;
|
content: OneTalkRenderedCard;
|
||||||
contentFingerprint: string;
|
contentFingerprint: string;
|
||||||
observedAtMs: number;
|
observedAtMs: number;
|
||||||
};
|
};
|
||||||
@@ -220,17 +236,77 @@ export const isSameOneTalkRenderedCardContent = (
|
|||||||
right: OneTalkRenderedCardContent,
|
right: OneTalkRenderedCardContent,
|
||||||
): boolean => canonicalize(left) === canonicalize(right);
|
): boolean => canonicalize(left) === canonicalize(right);
|
||||||
|
|
||||||
/** 克隆跨世界传输的受限卡片快照,拒绝保留 Fiber 或 raw props 引用。 */
|
/** 克隆跨世界传输的受限卡片快照,拒绝保留框架对象或 raw props 引用。 */
|
||||||
export const cloneOneTalkRenderedCardContent = (
|
export const cloneOneTalkRenderedCardContent = (
|
||||||
content: OneTalkRenderedCardContent,
|
content: OneTalkRenderedCardContent,
|
||||||
): OneTalkRenderedCardContent => JSON.parse(canonicalize(content)) as 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 的受控渲染卡片内容。 */
|
/** 解码精确 shape 的受控渲染卡片内容。 */
|
||||||
export const isOneTalkRenderedCardContent = (
|
export const isOneTalkRenderedCardContent = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is OneTalkRenderedCardContent => {
|
): value is OneTalkRenderedCardContent => {
|
||||||
if (!isPlainRecord(value) || value.version !== ONETALK_RENDERED_CARD_CONTENT_VERSION)
|
if (!isPlainRecord(value) || value.version !== ONETALK_RENDERED_CARD_CONTENT_VERSION)
|
||||||
return false;
|
return false;
|
||||||
|
if (!("kind" in value)) return isOneTalkRenderedCard(value);
|
||||||
if (value.kind === "rendered_inquiry") {
|
if (value.kind === "rendered_inquiry") {
|
||||||
return (
|
return (
|
||||||
hasExactKeys(value, [
|
hasExactKeys(value, [
|
||||||
@@ -319,7 +395,7 @@ const isObservation = (value: unknown): value is OneTalkRenderedCardObservation
|
|||||||
]) &&
|
]) &&
|
||||||
isOneTalkRenderedCardIdentifier(value.conversationId) &&
|
isOneTalkRenderedCardIdentifier(value.conversationId) &&
|
||||||
isOneTalkRenderedCardIdentifier(value.messageId) &&
|
isOneTalkRenderedCardIdentifier(value.messageId) &&
|
||||||
isOneTalkRenderedCardContent(value.content) &&
|
isOneTalkRenderedCard(value.content) &&
|
||||||
isOneTalkRenderedCardIdentifier(value.contentFingerprint) &&
|
isOneTalkRenderedCardIdentifier(value.contentFingerprint) &&
|
||||||
isSafeNonNegativeInteger(value.observedAtMs) &&
|
isSafeNonNegativeInteger(value.observedAtMs) &&
|
||||||
value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content);
|
value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content);
|
||||||
@@ -361,7 +437,7 @@ export const createOneTalkRenderedCardObservedFrame = (
|
|||||||
type: "rendered.card.observed",
|
type: "rendered.card.observed",
|
||||||
requestId: frame.requestId,
|
requestId: frame.requestId,
|
||||||
scope: frame.scope,
|
scope: frame.scope,
|
||||||
payload: { ...observation, content: cloneOneTalkRenderedCardContent(observation.content) },
|
payload: { ...observation, content: cloneOneTalkRenderedCard(observation.content) },
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 创建对应精确 observation 的服务端 ACK。 */
|
/** 创建对应精确 observation 的服务端 ACK。 */
|
||||||
|
|||||||
@@ -133,14 +133,9 @@ const textContent = {
|
|||||||
test("strictly validates rendered-card frames, canonical fingerprints, and in-place updates", () => {
|
test("strictly validates rendered-card frames, canonical fingerprints, and in-place updates", () => {
|
||||||
const content = {
|
const content = {
|
||||||
version: 1 as const,
|
version: 1 as const,
|
||||||
kind: "rendered_order" as const,
|
status: "Paid",
|
||||||
title: "Order summary",
|
total: "US $10.00",
|
||||||
products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }],
|
image: "https://img.alicdn.com/item.jpg",
|
||||||
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" },
|
|
||||||
};
|
};
|
||||||
assert.equal(isOneTalkRenderedCardContent(content), true);
|
assert.equal(isOneTalkRenderedCardContent(content), true);
|
||||||
const fingerprint = createOneTalkRenderedCardContentFingerprint(content);
|
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.deepEqual(decodeOneTalkFrame(frame), { ok: true, frame });
|
||||||
assert.equal(
|
assert.equal(
|
||||||
isSameOneTalkRenderedCardContent(content, {
|
isSameOneTalkRenderedCardContent(content, {
|
||||||
...content,
|
image: "https://img.alicdn.com/item.jpg",
|
||||||
status: { text: "Paid", code: "paid" },
|
total: "US $10.00",
|
||||||
|
status: "Paid",
|
||||||
|
version: 1,
|
||||||
}),
|
}),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
@@ -171,15 +168,7 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl
|
|||||||
...frame,
|
...frame,
|
||||||
payload: {
|
payload: {
|
||||||
...frame.payload,
|
...frame.payload,
|
||||||
content: {
|
content: { ...content, image: "https://img.alicdn.com/item.jpg?token=x" },
|
||||||
...content,
|
|
||||||
products: [
|
|
||||||
{
|
|
||||||
...content.products[0],
|
|
||||||
imageUrl: "https://img.alicdn.com/item.jpg?token=x",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
|
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
|
||||||
@@ -207,19 +196,96 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl
|
|||||||
paymentAmountCurrency: "USD",
|
paymentAmountCurrency: "USD",
|
||||||
statusMessageKey: "paid",
|
statusMessageKey: "paid",
|
||||||
actions: [],
|
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 });
|
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 = {
|
const jpegContent = {
|
||||||
version: ONETALK_CONTENT_VERSION,
|
version: ONETALK_CONTENT_VERSION,
|
||||||
kind: "image" as const,
|
kind: "image" as const,
|
||||||
|
|||||||
Reference in New Issue
Block a user