Merge branch 'dev' of github.com:sinanyuntu/trade-message-center into dev

This commit is contained in:
YBF
2026-09-16 19:20:51 +08:00
58 changed files with 3847 additions and 1596 deletions
@@ -94,7 +94,7 @@ Bright WebSocket
- `message-observer/send-observation.ts` 保持发送 pending、候选 ID、timeout 和 settle 的唯一 owner;命令侧通过 execute/executeImage/executeFile 使用。`image-send.ts` 保持原生媒体上传拦截器和关联状态,不复制或搬迁模块级状态容器。
- profile observer 可将 `syncData` 的 direct profiles 再次发布;它不维护 `seen`、baseline 或其他上传资格状态。snapshot 只读取当前已加载资料,collect 只读取一个明确 conversationId;两者都复用已安装 observer,命令只返回统计,不能再二次 publish。
- 新 installer、publisher 和 dispatcher 不拥有 Map、Set、timer、账本或连接。消息投递、profile/buyer 账本、ACK、页面请求关联及 Bright 生命周期继续归各原 Service Worker owner。
- `card-observer/entry.ts` 以 MutationObserver 批次作为 rendered-card 的唯一主动触发。`.message-item-wrapper` 只表示 React 已挂载,不表示 Fiber/template 已齐备:首次扫描及该 wrapper 子树的 `childList`、attributes 或 text 变化都必须立即重新读取;只有 reader 成功产生 exact 白名单 observation 后按 `conversationId + messageId + fingerprint` 去重。reader 返回 `null` 不写 bridge/ledger,并等待下一次该 wrapper 的 DOM 变化;不得全局轮询、用 DOM 文案兜底或把 raw Fiber/props 跨 MAIN。
- `card-observer/entry.ts` 以 MutationObserver 批次作为 rendered-card 的唯一主动触发。reader 以当前 single conversation 加 card/wrapper `data-expinfo` 的严格消息 identity 为键;任一已经出现的会话候选字段空、非法或冲突时 fail closed,不能忽略后回退。它只发布无 `kind``{ version: 1, ...namedFields }`,并只可由一个可见锚点分派内部字段解析器:订单=`订单状态`,询盘=`详细需求`,商品=`最小订购量`/`Minimum order`。零个或多个锚点命中时不发布;这个内部解析分派绝不进入 payload、bridge 或数据库。所有图片是从已验证 owner 取第一张安全 URL 的单值 `image`。订单输出可见“订单”标题、商品图及已审核 `productCount``status``total``deliveryDate``shippingAddress`;询盘从 `.description-container` 读取保留换行的 `innerText`,输出 `title``image``purchaseQuantity``reference`;商品在同一 `.dx-event-node` owner 内以三段 MOQ label/value/unit 定位标题、价格和安全图片,MOQ 输出组合字符串。`images``productImages``attributes``promotionTexts` 与对象 MOQ 不是合法公开字段。模板 ID 和路径只可用于调试/fixture,绝不能作为采集准入或字段映射门槛。读取时排除 `template``hidden``aria-hidden``display:none``visibility:hidden` 祖先,图片必须通过无 query/fragment 的安全 URL guard;未知或不安全单字段省略,动作节点不投影。成功后按 `conversationId + messageId + fingerprint` 去重;不得按 cardType/messageType/template 推断业务类型,也不得全局轮询、使React/Fiber/props 或把原始 metadata 跨 MAIN。
#### Validation & Error Matrix
@@ -109,8 +109,8 @@ Bright WebSocket
| 未知 action | rejected_before_send/invalid_request,不访问 SDK 或 snapshot |
| send 的 SDK 本地接受、超时或歧义 | 保持既有发送 SOP,不增加重试或假成功 |
| 历史命令返回 batch | 使用同一 publisher,不新建发送关联器或上报管线 |
| rendered-card wrapper 先挂载、后补齐 Fiber/template | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 |
| rendered-card reader 仍无完整白名单模板 | 不发布、不写 ledger;仅等待该 wrapper 的下一次子树变化 |
| rendered-card 已挂载后文本/图片或 metadata 才补齐 | 每个相关 DOM 变化批次立即重新读取;成功只发布一次 |
| identity、可见快照或安全图片 URL 不完整 | 不发布、不写 ledger;仅等待该 card 的下一次子树变化 |
#### Good / Base / Bad Cases
@@ -122,7 +122,7 @@ Bright WebSocket
- `onetalk-page-flow-boundaries.test.js` 覆盖 publisher 双消费顺序、诊断-only/空批次、snapshot/collect exact payload、targeted collect、未知动作与实际页面入口安装顺序。
- `onetalk-send-page.test.js` 保留 send/history 的输入、结果、tooltip 和发布行为;`onetalk-send-observation.test.js` 与媒体测试保留唯一确认、timeout/异常及不重试断言。
- `onetalk-rendered-card-observer.test.js` 覆盖 wrapper 首次读取失败、后续子树/属性/文本变化后立即重新读取并只生成一次 approved observation。
- `onetalk-rendered-card-reader.test.js` 覆盖 card/wrapper identity、三个单锚点解析器、零/多锚点 fail-closed、嵌套商品字段、询盘正文换行、隐藏/template 文本排除和安全图片 URL;`onetalk-rendered-card-observer.test.js` 覆盖后续子树/属性/文本变化后立即重新读取并只生成一次 approved observation。
- 对构建后的 MAIN IIFE 执行 `scripts/verify-release-bundle.mjs`,验证实际 shared observation 驱动消息上报及单次发送确认;这属于无网络 Node VM 冒烟,真实 Chromium/OneTalk/Bright 联调需单独标记。
#### Wrong vs Correct
@@ -40,7 +40,7 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
2. MAIN 只调用一次 sendUIMessages({ cid: conversationId, conversationCode: conversationId, content, ext }),不调用 sendMessage、send 或 sendTextMessage,不执行 DOM/fallback 发送;`cid``conversationCode` 必须同值。
3. SDK 调用前登记短生命周期的发送观察关联;现有 OneTalk WebSocket 旁路 observer 直接消费解析后的消息批次,不使用 confirmation waiter 或 window confirmation listener。
4. SDK Promise 返回值只表示本地受理(通常含 opId)。只有 WebSocket 旁路观察到目标会话的完整 direction: sent 消息,才能生成 confirmed_sent。
5. 观察消息必须匹配 command 的目标 conversationId(观察消息中的 conversationId),并通过完整 OneTalkMessage guard。若 SDK 返回候选 message ID,候选 ID 是主关联键:只按目标会话、`direction: "sent"`、完整消息和 ID 匹配确认,不再额外套正文或时间窗口;候选 ID 不匹配时不得退回正文匹配。只有没有候选 ID 时,才按正文与发送时间窗口匹配
5. 观察消息必须匹配 command 的目标 conversationId(观察消息中的 conversationId),并通过完整 OneTalkMessage guard。若 SDK 返回候选 message ID,候选 ID 是主关联键:只按目标会话、`direction: "sent"`、完整消息和 ID 匹配确认,不再额外套正文或时间窗口;候选 ID 不匹配时不得退回正文匹配。只有没有候选 ID 时,才按正文或媒体指纹匹配;不得把 `sentAtMs` 早于本地登记 pending 的时刻作为拒绝条件。仅拒绝晚于当前观测时刻五秒以上的未来 `sentAtMs`,防止未来时间戳误关联
6. 每个发送各自登记一个 pending。对每条完整 sent observation,只计算它与当前 pending 的匹配集合:唯一匹配时仅确认该项;零匹配时不改变任何 pending;多重匹配时仅将匹配集合结算为 delivery_unknown/send_ambiguous。观测消息不得结束未匹配的 pending。
7. 媒体的 target/current 身份检查只属于 native 发送前的不可逆调用围栏;不得传入 observer 作为清理其它 pending 的条件。SDK 异常、WebSocket 断线、旁路事实不完整、观察超时或无法唯一关联时,返回 delivery_unknown 加有限 reason;不得自动重试或伪造消息。
8. 发送观察关联器是 MAIN send handler 的必需依赖;必须先登记 pending observation 再调用 SDK。关联器缺失属于 wiring 错误,必须 fail closed,不得直调 SDK 并立即返回 unknown 作为 fallback。
@@ -60,6 +60,8 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
| 仅返回 opId/undefined,没有匹配旁路事实 | delivery_unknown/send_state_lost |
| 有候选 message ID 且 ID 匹配,但消息超出正文时间窗口 | 仍可按 ID 确认;时间窗口不适用于候选 ID 关联 |
| 有候选 message ID 不匹配,但正文相同 | 不确认,不退回正文匹配,最终 delivery_unknown/send_state_lost |
| 无候选 ID 的完整匹配消息,其 `sentAtMs` 早于本地登记 pending 的时刻 | 仍确认;本地登记时刻不是关联条件 |
| 无候选 ID 的完整匹配消息,其 `sentAtMs` 晚于当前观测时刻五秒以上 | 不确认,防止未来时间戳误关联 |
| 观察消息错误会话、错误方向或缺必填字段 | 不确认,最终 delivery_unknown/send_state_lost |
| 一条完整 sent observation 仅匹配一个 pending,另有其它 pending 等待 | 只确认匹配项;其它 pending 保持等待 |
| 同一条 observation 匹配多个 pending,另有不匹配 pending 等待 | 仅匹配集合为 delivery_unknown/send_ambiguous;不匹配项保持等待 |
@@ -71,6 +73,7 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
- Good:唯一同账号页面接收 commandMAIN 调用一次 sendUIMessagesWebSocket 旁路观察到目标会话完整 sent message,再发合法 send.confirmation。
- Good:当前打开 conversation-A、目标为 conversation-B;不改变页面 selected 状态,SDK input 使用 `cid=B`(并保留同值 `conversationCode=B`)。
- Good:SDK 未返回候选 ID 时,旁路观察的完整目标消息即使 `sentAtMs` 早于本地登记 pending 的时刻,仍按正文或媒体指纹确认。
- BaseSDK 返回 { opId } 但观察链尚未闭合,发送合法 delivery_unknown/send_state_lost,不承诺成功。
- Bad:只传 `conversationCode=B`、把 URL conversationId 当 SPA 会话、调用旧 API、把 opId 当 confirmed_sent、用页面文本当成功证据或自动重试不确定发送。
@@ -78,7 +81,7 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
- Service Worker:发送只要求唯一同账号页面;当前 selected 为其它会话、零 selected、多 selected 时仍投递;同账号多页面不广播;历史 sync 仍保持精确路由。
- MAIN handler:断言只调用一次 sendUIMessages,参数包含 `cid`、同值的兼容 `conversationCode`、字符串 content 和可选 ext;不调用旧 API、DOM 或 fallback;缺少 cid 的输入不得进入 SDK。
- WebSocket observer/correlator:覆盖完整 sent、错误会话、received、不完整消息、候选 ID 匹配(含超出正文时间窗口)/不匹配(不得正文回退)、无候选 ID 的正文/时间窗口、legacy `sentAt`、超时,以及 text/image/file pending 的唯一匹配仅结算自身、多个命中只结算匹配集合、未命中项继续等待;断言缺失 correlator wiring 不会直调 SDK。
- WebSocket observer/correlator:覆盖完整 sent、错误会话、received、不完整消息、候选 ID 匹配(含超出正文时间窗口)/不匹配(不得正文回退)、无候选 ID 的正文或媒体指纹匹配、早于本地 pending 登记时刻的 `sentAtMs` 仍确认、晚于当前观测时刻五秒以上的 `sentAtMs` 拒绝、legacy `sentAt`、超时,以及 text/image/file pending 的唯一匹配仅结算自身、多个命中只结算匹配集合、未命中项继续等待;断言缺失 correlator wiring 不会直调 SDK。
- Service Worker/contract:只接受完整 sent message 作为 confirmed_sentopId/undefined/缺字段/错误方向/缺 reason 均收敛到合法三态。
- 联调 smoke:当前打开非目标会话时发送普通字符串,确认 SDK 由 `cid` 选中目标会话(而不是 SDK.context.cid)、WebSocket sent observation、server inbound confirmation 和随后 send.result;页面出现文本或 SDK 返回 opId 不能作为完成证据。
@@ -100,7 +103,8 @@ const accepted = await messageService.sendUIMessages({
ext,
});
void accepted;
// 有候选 ID 时只按 ID 关联;无候选 ID 时按正文和时间窗口关联。
// 有候选 ID 时只按 ID 关联;无候选 ID 时按正文或媒体指纹关联。
// 不用本地 pending 登记时刻拒绝较早的 sentAtMs;仅拒绝超过当前观测时刻五秒的未来值。
// 未注入 observer correlator 时应 fail closed,不能执行上面的 SDK 调用。
// 由现有 WebSocket observer 匹配完整 direction: "sent" 消息;否则 delivery_unknown。
\`\`\`
@@ -387,7 +387,7 @@ toOneTalkCenterMessage(
): OneTalkCenterMessage;
```
`OneTalkCenterMessageContent``inquiry``product``order` 分支分别可增加已批准的展示字段;`OneTalkMessageContent``OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。
`OneTalkCenterMessageContent` legacy `inquiry``product``order` 分支可保留已批准的历史展示字段;新的 type-agnostic named supplement 通过 `OneTalkCenterMessage.renderedCard?: OneTalkRenderedCard` 公开。`OneTalkMessageContent``OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。
### 3. Contracts
@@ -396,6 +396,7 @@ toOneTalkCenterMessage(
- product 的 `sourceUrl``productId` 永远来自基础消息,且必须与补全中的身份一致。补全 kind 或 product 身份不一致、补全形状非法时显式失败,不能丢弃补全、猜测类型或发布部分内容。
- `projectCenterMessage`、rendered-card update、live/history sync 和 send-confirmation 都只把基础 message 与 optional supplement 传给 `toOneTalkCenterMessage`;不得在调用点以 `renderedCardContent ?? message.content` 替换内容。
- 补全先到时,后续单条或批量 accepted 基础消息都必须按同一 `channelAccountId + conversationId + messageId` 受限读取已有 supplement,并在 `message.created` 投影前带回。提交、guard、ACK 和 publish 顺序保持不变。
- 新写入的 `renderedCard` 是 type-agnostic exact shape `{ version: 1, ...namedFields }`,没有 `kind``actions`、通用 `fields`、裸 `texts`/`imageUrls` 或 DOM/template metadata;商品 MOQ 只用 `{ value, unit }`。它只能作为顶层 `renderedCard` 附加,绝不替换或补写基础 `content`,也不要求基础消息先存在。legacy typed supplement 仅为既有事实的读取兼容保留。
### 4. Validation & Error Matrix
@@ -406,18 +407,19 @@ toOneTalkCenterMessage(
| rendered product 的 URL 或 product ID 不一致 | 投影失败,不以补全覆盖基础身份 |
| unknown key、错误嵌套或 ledger 字段进入 Center content | strict guard 拒绝 |
| supplement 先到、base message 后由 batch live accepted | repository 带回 supplement`message.created` 使用基础 kind 加批准字段 |
| generic supplement 先到、base message 后到 | repository 带回同一复合 identity 的 snapshot`message.created.content` 不变,仅有顶层 `renderedCard` |
| duplicate/conflict supplement 或补全写失败 | 保持既有 ACK/冲突语义;不改写基础消息事实 |
### 5. Good / Base / Bad Cases
- GoodHTTP、`message.created``message.updated` 都调用同一个投影,订单公开为 `kind: "order"` 并可带 `status`/`payment`,内部 `rendered_order` 不离开服务端边界
- GoodHTTP、`message.created``message.updated` 都调用同一个投影generic snapshot 只作为顶层 `renderedCard`,基础 `content.kind` 不变
- Base:基础 message 先到且没有 supplement 时,后续读取保留原始 shape;补全到达后只发一次同一基础 kind 的 `message.updated`
- Badrepository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。
### 6. Tests Required
- Contract三种基础 kind 的无补全 exact shape、补全后的批准可选字段、unknown key/错误嵌套拒绝,以及 supplement kind/product identity 不一致拒绝。
- Read/WebSocketHTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖补全后到、补全先到后单条 live、补全先到后 batch live 和 send confirmation,断言公开 kind 永不为 `rendered_*`
- Contractgeneric `renderedCard` exact shape、unknown key/错误嵌套拒绝,以及 legacy supplement kind/product identity 不一致拒绝。
- Read/WebSocketHTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖 generic supplement 后到、先到后单条 live、先到后 batch live,断言基础 content 不变而仅顶层 snapshot 出现
- Repositorybatch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。
### 7. Wrong vs Correct
@@ -426,7 +428,7 @@ toOneTalkCenterMessage(
// Wrong: internal supplement replaces the public discriminated content.
toOneTalkCenterMessage({ ...message, content: renderedCardContent ?? message.content });
// Correct: the sole projection validates and merges two separate facts.
// Correct: the sole projection validates two separate facts and adds generic DOM data beside content.
toOneTalkCenterMessage(message, renderedCardContent);
```
@@ -0,0 +1,3 @@
{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/prd.md","reason":"Acceptance criteria and explicit exclusions for review."}
{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/design.md","reason":"Data flow, ownership, migration, and fail-closed design review."}
{"file":".trellis/tasks/09-16-onetalk-dom-secondary-card-collection/implement.md","reason":"Required verification and rollback checks."}
@@ -0,0 +1,64 @@
# OneTalk DOM 二次卡片采集重构设计
## 目标与边界
二次采集以 DOM-only reader 替代 React/Fiber reader,并独立保存不带 `kind` 的命名补全字段。它不判断也不覆盖基础消息 `order``inquiry``product` 或其它 kind;补全可先于基础消息保存,后续读取或实时发布再由完整复合 identity 关联。
`renderedCard` 是 exact-shape、versioned 公共值,至少包含一个已批准字段,但绝不含 `kind`、模板 ID、DOM path、class、`data-expinfo`、HTML、style、React props、URL query/fragment 或动作参数:
```ts
type OneTalkRenderedCard = {
version: 1;
title?: string;
image?: string;
price?: string;
minimumOrder?: string;
purchaseQuantity?: string;
requirementText?: string;
reference?: string;
productCount?: { display: string; value: number | null };
status?: string;
total?: string;
deliveryDate?: string;
shippingAddress?: string;
};
```
字段是可选的,因不同模板本来显示不同内容;空对象、未知键、无安全字段或不符合模板映射的卡 fail closed。图片仍须为 approved host、HTTPS、无 query/fragment 的 URL。
## 数据流与所有权
```text
DOM card + selected conversation
-> structure-based field reader in MAIN
-> generic named renderedCard observation
-> page bridge -> SW ledger -> rendered.card.observed -> server JSONB
-> base-message read/create/update by composite identity
-> OneTalkCenterMessage.renderedCard
```
MAIN 是 DOM 结构和可见节点读取的唯一 ownershared contract 拥有 `OneTalkRenderedCard` exact shape、URL/text guards、fingerprint/clone/equalityserver repository 拥有独立事实的 first-content-wins/conflictread 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 objecttyped 历史内容不可重发。`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 readerstrict 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 projectiongeneric supplement 只作顶层字段,base `content.kind` 不变;保留 typed historical supplement 分支。
6. 改 0016/schema/migration tests:不允许 `rendered_dom_card`,接受 legacy typed 或无 kind/versioned generic object0015 只 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 evidencepage 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": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/chrome-extension",
"version": "0.8.27",
"version": "0.8.28",
"private": true,
"type": "module",
"scripts": {
@@ -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 @@
// 安装渲染卡片 observerDOM mutation 即时读取,另以当前列表周期重扫补足 Fiber 延迟就绪
// 安装 DOM-only rendered-card observer 并在页面关闭时释放资源
import type { OneTalkRenderedCardObservation } from "@trade-message-center/onetalk-contract";
import type { OneTalkPageWindow } from "../model.ts";
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
import { readOneTalkRenderedCard } from "./react-card-reader.ts";
import { readOneTalkRenderedCardFromDom } from "./dom-card-reader.ts";
const RENDERED_CARD_RESCAN_INTERVAL_MS = 1_000;
const CARD_SELECTOR = ".session-rich-content.card";
const messageListRootFor = (
document: NonNullable<OneTalkPageWindow["document"]>,
): Element | null => {
const firstMessage = document.querySelectorAll(".message-item-wrapper")[0];
if (!firstMessage) return null;
let parent = firstMessage.parentElement;
while (parent) {
if (parent.querySelectorAll(".message-item-wrapper").length > 1) return parent;
parent = parent.parentElement;
}
return firstMessage.parentElement;
const isCardElement = (value: unknown): value is Element =>
typeof value === "object" &&
value !== null &&
"matches" in value &&
typeof (value as { matches?: unknown }).matches === "function";
const cardsFor = (node: unknown): Element[] => {
const element = isCardElement(node)
? node
: typeof node === "object" && node !== null && "parentElement" in node
? (node as { parentElement?: unknown }).parentElement
: null;
if (!isCardElement(element)) return [];
const cards = new Set<Element>();
if (element.matches(CARD_SELECTOR)) cards.add(element);
const closest = element.closest?.(CARD_SELECTOR);
if (isCardElement(closest) && closest.matches(CARD_SELECTOR)) cards.add(closest);
for (const card of Array.from(element.querySelectorAll?.(CARD_SELECTOR) ?? [])) cards.add(card);
return [...cards];
};
const renderedCardWrapperFor = (node: Node): Element | null => {
const element = node instanceof Element ? node : node.parentElement;
return element?.matches(".message-item-wrapper")
? element
: (element?.closest(".message-item-wrapper") ?? null);
};
/** 安装 Fiber card reader;未验证类别一律不上传。 */
/** 安装当前页面的严格 DOM 卡片采集;未知或不完整卡不发送。 */
export const installOneTalkRenderedCardObserver = (
pageWindow: OneTalkPageWindow,
sink: (
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
) => void,
sink: (observations: OneTalkRenderedCardObservation[]) => void,
): void => {
const document = pageWindow.document;
if (!document || !pageWindow.MutationObserver) return;
const documentElement = (document as Document | undefined)?.documentElement;
if (!document || !documentElement || !pageWindow.MutationObserver) return;
const observedCardKeys = new Set<string>();
let disposed = false;
const inspect = (wrapper: Element): void => {
const publish = (card: Element): void => {
if (disposed) return;
const read = readOneTalkRenderedCard(pageWindow, wrapper);
if (!read) return;
const observationKey = JSON.stringify([
read.observation.conversationId,
read.observation.messageId,
read.observation.contentFingerprint,
const observation = readOneTalkRenderedCardFromDom(pageWindow, card);
if (!observation) return;
const key = JSON.stringify([
observation.conversationId,
observation.messageId,
observation.contentFingerprint,
]);
if (observedCardKeys.has(observationKey)) return;
observedCardKeys.add(observationKey);
sink([read.observation], [read.baseEvidence]);
if (observedCardKeys.has(key)) return;
observedCardKeys.add(key);
sink([observation]);
};
let root: Element | null = null;
let observingDocument = false;
const observer = new pageWindow.MutationObserver((records) => {
if (disposed) return;
if (messageListRootFor(document) !== root) {
bind();
return;
}
for (const record of records) {
if (root && record.target !== root && !root.contains(record.target)) continue;
const wrappers = new Set<Element>();
const targetWrapper = renderedCardWrapperFor(record.target);
if (targetWrapper) wrappers.add(targetWrapper);
for (const card of cardsFor(record.target)) publish(card);
for (const node of Array.from(record.addedNodes)) {
if (!(node instanceof Element)) continue;
const wrapper = renderedCardWrapperFor(node);
if (wrapper) wrappers.add(wrapper);
for (const wrapper of Array.from(node.querySelectorAll(".message-item-wrapper")))
wrappers.add(wrapper);
for (const card of cardsFor(node)) publish(card);
}
for (const wrapper of wrappers) inspect(wrapper);
}
});
const bind = (): void => {
if (disposed) return;
const nextRoot = messageListRootFor(document);
if (nextRoot === root && (root !== null || observingDocument)) return;
observer.disconnect();
root = nextRoot;
if (!root) {
observingDocument = true;
observer.observe(document as unknown as Node, { childList: true, subtree: true });
return;
}
observingDocument = false;
for (const wrapper of Array.from(root.querySelectorAll(".message-item-wrapper")))
inspect(wrapper);
observer.observe(root, {
attributes: true,
characterData: true,
childList: true,
subtree: true,
});
if (root.parentElement) observer.observe(root.parentElement, { childList: true });
};
const rescanCurrentMessageList = (): void => {
if (disposed) return;
if (messageListRootFor(document) !== root) {
bind();
return;
}
if (!root) return;
for (const wrapper of Array.from(root.querySelectorAll(".message-item-wrapper")))
inspect(wrapper);
};
bind();
const rescanTimer = globalThis.setInterval(
rescanCurrentMessageList,
RENDERED_CARD_RESCAN_INTERVAL_MS,
);
pageWindow.addEventListener("popstate", bind);
pageWindow.addEventListener("hashchange", bind);
for (const card of Array.from(document.querySelectorAll(CARD_SELECTOR))) publish(card);
observer.observe(documentElement, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["data-expinfo", "src", "style"],
});
pageWindow.addEventListener("pagehide", () => {
disposed = true;
globalThis.clearInterval(rescanTimer);
observer.disconnect();
});
};
@@ -1,337 +0,0 @@
// 从 OneTalk React Fiber 白名单投影三类渲染卡片;不让 raw props 离开 MAIN world。
import {
createOneTalkRenderedCardContentFingerprint,
isOneTalkRenderedCardContent,
isOneTalkRenderedCardImageUrl,
isOneTalkRenderedCardText,
normalizeOneTalkProductUrl,
type OneTalkRenderedCardContent,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import { isObjectRecord } from "../../../lib/guards.ts";
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
import { readConversationSelection } from "../page-context.ts";
import type { OneTalkPageWindow } from "../model.ts";
type FiberRecord = {
return?: FiberRecord | null;
child?: FiberRecord | null;
sibling?: FiberRecord | null;
memoizedProps?: unknown;
};
type ItemData = {
messageId: string;
conversationCode: string;
messageType: "rec" | "send";
sendTime: number;
msgType: number;
originalData: Record<string, unknown>;
};
export type OneTalkRenderedCardRead = {
observation: OneTalkRenderedCardObservation;
baseEvidence: OneTalkPageRenderedCardBaseEvidence;
};
type CardClassification =
| { kind: "order"; data: Record<string, unknown> }
| { kind: "inquiry"; data: Record<string, unknown> }
| { kind: "product"; data: Record<string, unknown> };
const MAX_FIBER_ANCESTORS = 60;
const MAX_FIBER_DESCENDANTS = 120;
const text = (value: unknown): string | null => (isOneTalkRenderedCardText(value) ? value : null);
const nullableText = (value: unknown): string | null => (value === null ? null : text(value));
const imageUrl = (value: unknown): string | null =>
isOneTalkRenderedCardImageUrl(value) ? value : null;
const scalarId = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0
? value
: typeof value === "number" && Number.isSafeInteger(value)
? String(value)
: null;
const nonNegativeInteger = (value: unknown): number | null =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
const fiberFor = (node: Node): FiberRecord | null => {
for (const key of Object.keys(node)) {
if (!key.startsWith("__reactFiber$")) continue;
const fiber = (node as unknown as Record<string, unknown>)[key];
if (isObjectRecord(fiber)) return fiber as FiberRecord;
}
return null;
};
const fiberIn = (wrapper: Element): FiberRecord | null =>
fiberFor(wrapper) ??
Array.from(wrapper.querySelectorAll("*"))
.map(fiberFor)
.find((value): value is FiberRecord => value !== null) ??
null;
const itemDataFor = (fiber: FiberRecord): ItemData | null => {
let cursor: FiberRecord | null | undefined = fiber;
for (let depth = 0; cursor && depth < MAX_FIBER_ANCESTORS; depth += 1) {
const current: FiberRecord = cursor;
cursor = current.return;
const props = current.memoizedProps;
if (!isObjectRecord(props) || !isObjectRecord(props.itemData)) continue;
const item = props.itemData;
const messageId = scalarId(item.messageId);
const conversationCode = text(item.conversationCode);
const messageType =
item.messageType === "rec" || item.messageType === "send" ? item.messageType : null;
const sendTime = nonNegativeInteger(item.sendTime);
const msgType = nonNegativeInteger(item.msgType);
if (
!messageId ||
!conversationCode ||
!messageType ||
sendTime === null ||
msgType === null ||
!isObjectRecord(item.originalData)
) {
return null;
}
return {
messageId,
conversationCode,
messageType,
sendTime,
msgType,
originalData: item.originalData,
};
}
return null;
};
const fibersFor = (fiber: FiberRecord): FiberRecord[] => {
const pending: FiberRecord[] = [fiber];
for (let index = 0; index < pending.length && index < MAX_FIBER_DESCENDANTS; index += 1) {
const current = pending[index]!;
if (current.child) pending.push(current.child);
if (current !== fiber && current.sibling) pending.push(current.sibling);
}
return pending.slice(0, MAX_FIBER_DESCENDANTS);
};
const templateDataFor = (
fiber: FiberRecord,
predicate: (data: Record<string, unknown>) => boolean,
): Record<string, unknown> | null => {
for (const current of fibersFor(fiber)) {
const props = current.memoizedProps;
if (!isObjectRecord(props) || !isObjectRecord(props.data) || !predicate(props.data))
continue;
return props.data;
}
return null;
};
const hasRendererCardType = (fiber: FiberRecord, cardType: number): boolean =>
fibersFor(fiber).some((current) => {
const props = current.memoizedProps;
return isObjectRecord(props) && props.cardType === cardType;
});
const productFor = (value: unknown): { imageUrl: string | null; title: string } | null => {
if (!isObjectRecord(value)) return null;
const title =
text(value.productName) ??
text(value.title) ??
text(value.productTitle) ??
text(value.name);
if (!title) return null;
const imageValues = [value.imageUrl, value.productImage, value.image].filter(
(image): image is unknown => image !== undefined && image !== null,
);
if (imageValues.some((value) => imageUrl(value) === null)) return null;
return {
imageUrl:
imageValues.map(imageUrl).find((value): value is string => value !== null) ?? null,
title,
};
};
const orderTemplate = (data: Record<string, unknown>): boolean =>
text(data.cardTitle) !== null &&
Array.isArray(data.productInfoList ?? data.productList) &&
text(data.orderStatusText) !== null &&
text(data.shippingAddress) !== null;
const inquiryTemplate = (data: Record<string, unknown>): boolean =>
isObjectRecord(data.inquiryCardDTO) &&
Array.isArray(data.displayProducts ?? data.products) &&
text(data.inquiryContent) !== null &&
scalarId(data.inquiryID) !== null;
const productTemplate = (data: Record<string, unknown>): boolean => {
const productAction = data.productAction;
return (
text(data.title) !== null &&
imageUrl(data.productImage) !== null &&
text(data.price) !== null &&
text(data.moq) !== null &&
text(data.moqUnit) !== null &&
isObjectRecord(productAction) &&
isObjectRecord(productAction.actionParams) &&
normalizeOneTalkProductUrl(productAction.actionParams.url) !== null
);
};
const classifyRenderedCard = (item: ItemData, fiber: FiberRecord): CardClassification | null => {
if (item.msgType === 10010 && item.originalData.cardType === 9) {
const data = templateDataFor(fiber, orderTemplate);
return data ? { kind: "order", data } : null;
}
if (item.msgType === 10010 && item.originalData.cardType === 6) {
const data = templateDataFor(fiber, inquiryTemplate);
return data ? { kind: "inquiry", data } : null;
}
if (item.msgType === 101 && hasRendererCardType(fiber, 54)) {
const data = templateDataFor(fiber, productTemplate);
return data ? { kind: "product", data } : null;
}
return null;
};
const orderContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
const products = data.productInfoList ?? data.productList;
if (!Array.isArray(products)) return null;
const projected = products.map(productFor);
const title = text(data.cardTitle);
const statusText = text(data.orderStatusText);
const shippingAddress = text(data.shippingAddress);
const totalDisplay = text(data.shouldPayAmount) ?? text(data.productAmount);
if (
!title ||
!statusText ||
!shippingAddress ||
!totalDisplay ||
projected.some((item) => item === null)
) {
return null;
}
const action = isObjectRecord(data.orderAction) ? data.orderAction : {};
return {
version: 1,
kind: "rendered_order",
title,
products: projected as { imageUrl: string | null; title: string }[],
productCount: nonNegativeInteger(data.productCount) ?? projected.length,
status: { code: scalarId(data.orderStatus), text: statusText },
payment: { totalDisplay, discountDisplay: nullableText(data.discountAmount) },
delivery: {
shippingAddress,
methodLabel: nullableText(data.shippingMethodText),
dateLabel: nullableText(data.deliveryTimeText),
},
action: {
label: nullableText(action.label) ?? nullableText(action.name),
status: nullableText(action.status),
},
};
};
const inquiryActionsFor = (data: Record<string, unknown>) =>
[
[data.detailStr, data.detailAction],
[data.quoteStr, data.quoteAction],
[data.actionName, data.inquiryAction],
].flatMap(([label, action]) => {
const approvedLabel = text(label);
return approvedLabel ? [{ label: approvedLabel, available: isObjectRecord(action) }] : [];
});
const inquiryContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
const products = data.displayProducts ?? data.products;
const requirementText = text(data.inquiryContent);
const inquiryReference = scalarId(data.inquiryID);
if (!Array.isArray(products) || !requirementText || !inquiryReference) return null;
const product = productFor(products[0]);
if (!product || !isObjectRecord(products[0])) return null;
const value = text(products[0].productMOQ);
const unit = text(products[0].productUnit);
if (!value || !unit) return null;
return {
version: 1,
kind: "rendered_inquiry",
product,
purchaseQuantity: { value, unit },
requirementText,
inquiryReference,
actions: inquiryActionsFor(data),
};
};
const productContentFor = (data: Record<string, unknown>): OneTalkRenderedCardContent | null => {
const productAction = data.productAction;
if (!isObjectRecord(productAction) || !isObjectRecord(productAction.actionParams)) return null;
const reference = normalizeOneTalkProductUrl(productAction.actionParams.url);
const title = text(data.title);
const productImage = imageUrl(data.productImage);
const priceDisplay = text(data.price);
const value = text(data.moq);
const unit = text(data.moqUnit);
if (!reference || !title || !productImage || !priceDisplay || !value || !unit) return null;
const serviceBadges = [data.benefitFirstText, data.benefitSecondText, data.promotionTag].reduce(
(badges: string[], value) => {
const badge = text(value);
return badge && !badges.includes(badge) ? [...badges, badge] : badges;
},
[],
);
return {
version: 1,
kind: "rendered_product",
product: { title, imageUrl: productImage, ...reference },
priceDisplay,
minimumOrder: { value, unit },
serviceBadges,
};
};
const contentFor = (classification: CardClassification): OneTalkRenderedCardContent | null => {
const content =
classification.kind === "order"
? orderContentFor(classification.data)
: classification.kind === "inquiry"
? inquiryContentFor(classification.data)
: productContentFor(classification.data);
return content && isOneTalkRenderedCardContent(content) ? content : null;
};
/** 读取一次受限 Fiber 卡片,并同时返回 downstream 所需的基础消息证据。 */
export const readOneTalkRenderedCard = (
pageWindow: OneTalkPageWindow,
wrapper: Element,
): OneTalkRenderedCardRead | null => {
const before = readConversationSelection(pageWindow);
if (before.kind !== "single") return null;
const fiber = fiberIn(wrapper);
if (!fiber) return null;
const item = itemDataFor(fiber);
if (!item || item.conversationCode !== before.conversationId) return null;
const classification = classifyRenderedCard(item, fiber);
if (!classification) return null;
const content = contentFor(classification);
const after = readConversationSelection(pageWindow);
if (!content || after.kind !== "single" || after.conversationId !== before.conversationId)
return null;
const observation = {
conversationId: item.conversationCode,
messageId: item.messageId,
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: Date.now(),
};
return {
observation,
baseEvidence: {
conversationId: item.conversationCode,
messageId: item.messageId,
direction: item.messageType === "rec" ? "received" : "sent",
sentAtMs: item.sendTime,
},
};
};
@@ -15,7 +15,6 @@ type PendingSend = {
kind: "text";
conversationId: string;
content: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
resolve: (result: PageCommandResult) => void;
timer?: ReturnType<typeof setTimeout>;
@@ -24,7 +23,6 @@ type PendingSend = {
type PendingImageSend = {
kind: "image";
conversationId: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
expected: {
sizeBytes: number;
@@ -40,7 +38,6 @@ type PendingImageSend = {
type PendingFileSend = {
kind: "file";
conversationId: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
expected: {
fileName: string;
@@ -161,12 +158,7 @@ const matches = (
);
}
const sentAtMs = message.sentAtMs;
if (
typeof sentAtMs !== "number" ||
sentAtMs < pending.sentAfterMs ||
sentAtMs > nowMs + MATCH_WINDOW_MS
)
return false;
if (typeof sentAtMs !== "number" || sentAtMs > nowMs + MATCH_WINDOW_MS) return false;
if (pending.kind === "text") return textOf(message) === pending.content;
return pending.kind === "image"
? matchesImage(pending.expected, message)
@@ -207,13 +199,11 @@ export const createSendObservationCorrelator = (
return {
execute: async (conversationId, content, ext, send) => {
const sentAfterMs = now();
const result = await new Promise<PageCommandResult>((resolve) => {
const record: PendingSend = {
kind: "text",
conversationId,
content,
sentAfterMs,
candidateMessageIds: new Set<string>(),
resolve,
};
@@ -245,12 +235,10 @@ export const createSendObservationCorrelator = (
return result;
},
executeImage: async (conversationId, expected, send, imageOptions = {}) => {
const sentAfterMs = now();
return new Promise<PageCommandResult>((resolve) => {
const record: PendingImageSend = {
kind: "image",
conversationId,
sentAfterMs,
candidateMessageIds: new Set<string>(),
expected,
resolve,
@@ -298,12 +286,10 @@ export const createSendObservationCorrelator = (
});
},
executeFile: async (conversationId, expected, send, fileOptions = {}) => {
const sentAfterMs = now();
return new Promise<PageCommandResult>((resolve) => {
const record: PendingFileSend = {
kind: "file",
conversationId,
sentAfterMs,
candidateMessageIds: new Set<string>(),
expected,
resolve,
@@ -36,7 +36,6 @@ import {
createOneTalkPageRenderedCardObservedMessage,
ONE_TALK_PAGE_BRIDGE_RECONNECT_SIGNAL,
type PageCommandResult,
type OneTalkPageRenderedCardBaseEvidence,
} from "./model.ts";
export type OneTalkPageCommandHandler = (
@@ -224,23 +223,16 @@ export const createOneTalkPageObservedSink = (
/** 创建只发送经 MAIN 白名单投影的渲染卡片补全 sink。 */
export const createOneTalkPageRenderedCardObservedSink = (
pageWindow: OneTalkMainPageBridgeWindow,
): ((
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
) => void) => {
): ((observations: OneTalkRenderedCardObservation[]) => void) => {
const origin = pageOrigin(pageWindow);
return (observations, baseEvidence: OneTalkPageRenderedCardBaseEvidence[]) => {
return (observations) => {
if (!origin || observations.length === 0) return;
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId) return;
postPageMessage(
pageWindow,
origin,
createOneTalkPageRenderedCardObservedMessage(
channelAccountId,
observations,
baseEvidence,
),
createOneTalkPageRenderedCardObservedMessage(channelAccountId, observations),
);
};
};
@@ -7,7 +7,7 @@ import {
isOneTalkContactProfile,
isOneTalkMessage,
isOneTalkBuyerFact,
isOneTalkRenderedCardContent,
isOneTalkRenderedCard,
type OneTalkBuyerFact,
type OneTalkContactProfile,
type OneTalkRenderedCardObservation,
@@ -82,22 +82,13 @@ export type OneTalkPageBuyerFactsObservedMessage = {
facts: OneTalkBuyerFact[];
};
/** MAIN 已完成白名单投影的渲染卡片补全;不携带 DOM/Fiber/raw props。 */
/** MAIN 已完成白名单投影的渲染卡片补全;不携带原始页面对象。 */
export type OneTalkPageRenderedCardObservedMessage = {
source: typeof ONE_TALK_PAGE_BRIDGE_SOURCE;
version: typeof ONE_TALK_PAGE_BRIDGE_VERSION;
type: "onetalk.page.rendered-card-observed";
channelAccountId: string;
observations: OneTalkRenderedCardObservation[];
/** MAIN-only proof used to compare against the durable base candidate; never enters wire. */
baseEvidence: OneTalkPageRenderedCardBaseEvidence[];
};
export type OneTalkPageRenderedCardBaseEvidence = {
conversationId: string;
messageId: string;
direction: "received" | "sent";
sentAtMs: number;
};
export type OneTalkPageHistoryProgress = {
@@ -531,14 +522,7 @@ const decodeRenderedCardObservedMessage = (
value: Record<string, unknown>,
): OneTalkPageRenderedCardObservedMessage | null => {
if (
!hasExactKeys(value, [
"source",
"version",
"type",
"channelAccountId",
"observations",
"baseEvidence",
]) ||
!hasExactKeys(value, ["source", "version", "type", "channelAccountId", "observations"]) ||
typeof value.channelAccountId !== "string" ||
value.channelAccountId.trim().length === 0 ||
!Array.isArray(value.observations) ||
@@ -561,32 +545,13 @@ const decodeRenderedCardObservedMessage = (
typeof observation.observedAtMs === "number" &&
Number.isSafeInteger(observation.observedAtMs) &&
observation.observedAtMs >= 0 &&
isOneTalkRenderedCardContent(observation.content) &&
isOneTalkRenderedCard(observation.content) &&
observation.contentFingerprint ===
createOneTalkRenderedCardContentFingerprint(observation.content),
)
)
return null;
const observations = value.observations as Record<string, unknown>[];
const baseEvidence = value.baseEvidence;
if (
!Array.isArray(baseEvidence) ||
baseEvidence.length !== observations.length ||
!baseEvidence.every(
(evidence, index) =>
isPlainRecord(evidence) &&
hasExactKeys(evidence, ["conversationId", "messageId", "direction", "sentAtMs"]) &&
typeof evidence.conversationId === "string" &&
typeof evidence.messageId === "string" &&
(evidence.direction === "received" || evidence.direction === "sent") &&
typeof evidence.sentAtMs === "number" &&
Number.isSafeInteger(evidence.sentAtMs) &&
evidence.sentAtMs >= 0 &&
evidence.conversationId === observations[index]?.conversationId &&
evidence.messageId === observations[index]?.messageId,
)
)
return null;
return {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
@@ -599,12 +564,6 @@ const decodeRenderedCardObservedMessage = (
contentFingerprint: observation.contentFingerprint as string,
observedAtMs: observation.observedAtMs as number,
})),
baseEvidence: baseEvidence.map((evidence) => ({
conversationId: evidence.conversationId as string,
messageId: evidence.messageId as string,
direction: evidence.direction as "received" | "sent",
sentAtMs: evidence.sentAtMs as number,
})),
};
};
@@ -792,7 +751,6 @@ export const createOneTalkPageBuyerFactsObservedMessage = (
export const createOneTalkPageRenderedCardObservedMessage = (
channelAccountId: string,
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
): OneTalkPageRenderedCardObservedMessage => ({
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
@@ -802,7 +760,6 @@ export const createOneTalkPageRenderedCardObservedMessage = (
...observation,
content: JSON.parse(JSON.stringify(observation.content)),
})),
baseEvidence: baseEvidence.map((evidence) => ({ ...evidence })),
});
/** 创建发送到页面的命令消息。 */
@@ -321,7 +321,7 @@ export class OneTalkPageRuntimeHost {
const coordinator = options.getActiveRenderedCardCoordinator?.();
if (!coordinator || channelAccountId !== this.lastPageIdentity?.channelAccountId)
return;
await coordinator.observe(message.observations, message.baseEvidence);
await coordinator.observe(message.observations);
void sender;
},
onPageMessage: () => undefined,
@@ -8,13 +8,9 @@ import {
} from "@trade-message-center/onetalk-contract";
import type { OneTalkBrightClient, OneTalkBrightClientState } from "./transport/bright-client.ts";
import type { OneTalkRenderedCardLedgerStore } from "./storage.ts";
import type { OneTalkPageRenderedCardBaseEvidence } from "../page-bridge/model.ts";
export type OneTalkRenderedCardCoordinator = {
observe: (
observations: OneTalkRenderedCardObservation[],
baseEvidence?: OneTalkPageRenderedCardBaseEvidence[],
) => Promise<void>;
observe: (observations: OneTalkRenderedCardObservation[]) => Promise<void>;
handleFrame: (frame: OneTalkFrame) => void;
handleStatus: (state: OneTalkBrightClientState) => void;
handlePageReady: () => Promise<void>;
@@ -89,29 +85,12 @@ export const createOneTalkRenderedCardCoordinator = (options: {
report(error);
}
};
const observe = async (
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[] = [],
): Promise<void> => {
const observe = async (observations: OneTalkRenderedCardObservation[]): Promise<void> => {
if (disposed) return;
if (
baseEvidence.length !== observations.length ||
!observations.every(
(observation, index) =>
baseEvidence[index]?.conversationId === observation.conversationId &&
baseEvidence[index]?.messageId === observation.messageId,
)
)
return;
for (const [index, observation] of observations.entries()) {
const evidence = baseEvidence[index]!;
for (const observation of observations) {
await options.ledger.observe({
channelAccountId: options.scope.channelAccountId,
observation,
baseEvidence: {
direction: evidence.direction,
sentAtMs: evidence.sentAtMs,
},
});
if (disposed) return;
}
@@ -1,7 +1,7 @@
// 持久化 OneTalk 同步账本与页面观察
import {
cloneOneTalkRenderedCardContent,
cloneOneTalkRenderedCard,
isOneTalkMessage,
type OneTalkContactProfile,
type OneTalkObservedMessage,
@@ -9,7 +9,7 @@ import {
type OneTalkSyncAnomalyCode,
type OneTalkSyncMode,
type OneTalkSyncResult,
type OneTalkRenderedCardContent,
type OneTalkRenderedCard,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
@@ -141,13 +141,9 @@ export type OneTalkRenderedCardLedgerRecord = {
channelAccountId: string;
conversationId: string;
messageId: string;
content: OneTalkRenderedCardContent;
content: OneTalkRenderedCard;
contentFingerprint: string;
observedAtMs: number;
/** MAIN Fiber identity evidence retained with the independently uploaded card fact. */
baseDirection: "received" | "sent";
/** MAIN Fiber sendTime evidence retained with the independently uploaded card fact. */
baseSentAtMs: number;
status: "pending_ack" | "confirmed" | "rejected";
requestId?: string;
firstObservedAt: number;
@@ -162,10 +158,6 @@ export type OneTalkRenderedCardLedgerStore = {
observe: (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
baseEvidence: {
direction: "received" | "sent";
sentAtMs: number;
};
observedAt?: number;
}) => Promise<OneTalkRenderedCardLedgerRecord>;
get: (
@@ -536,7 +528,7 @@ export const createOneTalkRenderedCardLedgerStore = (
const completion = transactionResult(transaction);
transaction.objectStore(ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME).put({
...value,
content: cloneOneTalkRenderedCardContent(value.content),
content: cloneOneTalkRenderedCard(value.content),
});
await completion;
};
@@ -556,7 +548,6 @@ export const createOneTalkRenderedCardLedgerStore = (
const observeOne = async (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
baseEvidence: { direction: "received" | "sent"; sentAtMs: number };
observedAt?: number;
}): Promise<OneTalkRenderedCardLedgerRecord> => {
const current = await get(
@@ -589,11 +580,9 @@ export const createOneTalkRenderedCardLedgerStore = (
channelAccountId: input.channelAccountId,
conversationId: input.observation.conversationId,
messageId: input.observation.messageId,
content: cloneOneTalkRenderedCardContent(input.observation.content),
content: cloneOneTalkRenderedCard(input.observation.content),
contentFingerprint: input.observation.contentFingerprint,
observedAtMs: input.observation.observedAtMs,
baseDirection: input.baseEvidence.direction,
baseSentAtMs: input.baseEvidence.sentAtMs,
status: "pending_ack",
firstObservedAt: observedAt,
updatedAt: observedAt,
@@ -604,7 +593,6 @@ export const createOneTalkRenderedCardLedgerStore = (
const observe = (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
baseEvidence: { direction: "received" | "sent"; sentAtMs: number };
observedAt?: number;
}): Promise<OneTalkRenderedCardLedgerRecord> => {
const key = candidateKey(
@@ -369,7 +369,10 @@ export class ObservationPipeline {
)
return;
const key = scopedKey(channelAccountId, progress.conversationId);
const token = this.lifecycle.capturePageObservation(progress.conversationId, historyRequestId);
const token = this.lifecycle.capturePageObservation(
progress.conversationId,
historyRequestId,
);
if (token === null) return;
const previous = this.observationWrites.get(key) ?? Promise.resolve();
const current = previous.then(async () => {
@@ -310,8 +310,25 @@ test("decodes one versioned JSON envelope and rejects malformed shapes", () => {
);
});
test("rejects rendered-card observations with forged identity or fingerprint fields", () => {
test("accepts only generic rendered-card observations and rejects forged fields", () => {
const content = {
version: 1,
title: "Order",
image: "https://img.alicdn.com/order.jpg",
productCount: { display: "5 products", value: 5 },
status: "Paid",
};
const observation = {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_000,
};
const message = createOneTalkPageRenderedCardObservedMessage("account-1", [observation]);
assert.deepEqual(decodeOneTalkPageMessage(message), message);
const legacyContent = {
version: 1,
kind: "rendered_order",
title: "Order",
@@ -322,26 +339,12 @@ test("rejects rendered-card observations with forged identity or fingerprint fie
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const observation = {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_000,
const legacyObservation = {
...observation,
content: legacyContent,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(legacyContent),
};
const message = createOneTalkPageRenderedCardObservedMessage(
"account-1",
[observation],
[
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: "received",
sentAtMs: 1_699_999_999_999,
},
],
);
assert.deepEqual(decodeOneTalkPageMessage(message), message);
assert.equal(decodeOneTalkPageMessage({ ...message, observations: [legacyObservation] }), null);
assert.equal(
decodeOneTalkPageMessage({
...message,
@@ -366,7 +369,7 @@ test("rejects rendered-card observations with forged identity or fingerprint fie
assert.equal(decodeOneTalkPageMessage({ ...message, baseEvidence: [] }), null);
});
test("decodes canonical rendered products and rejects forged product references", () => {
test("rejects legacy typed rendered products at the observed-frame boundary", () => {
const content = {
version: 1,
kind: "rendered_product",
@@ -393,47 +396,9 @@ test("decodes canonical rendered products and rejects forged product references"
type: "onetalk.page.rendered-card-observed",
channelAccountId: "account-1",
observations: [observation],
baseEvidence: [
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: "received",
sentAtMs: 1_699_999_999_999,
},
],
};
assert.deepEqual(decodeOneTalkPageMessage(message), message);
for (const invalidContent of [
{ ...content, storeImageUrl: "https://img.alicdn.com/store.png" },
{
...content,
product: {
...content.product,
sourceUrl: "https://img.alicdn.com/product/widget.jpg",
},
},
{
...content,
product: { ...content.product, productId: "987654321" },
},
]) {
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [
{
...observation,
content: invalidContent,
contentFingerprint:
createOneTalkRenderedCardContentFingerprint(invalidContent),
},
],
}),
null,
);
}
assert.equal(decodeOneTalkPageMessage(message), null);
});
test("deep-clones extended buyer facts at the page bridge boundary", () => {
@@ -1,4 +1,4 @@
// 验证渲染卡片 durable-first、基础确认门和精确 ACK
// 验证 type-agnostic 渲染卡片 durable-first精确 ACK
import assert from "node:assert/strict";
import test from "node:test";
@@ -9,14 +9,9 @@ import { createOneTalkRenderedCardCoordinator } from "../src/onetalk/service-wor
const scope = { channelAccountId: "account-1", deviceId: "device-1" };
const content = {
version: 1,
kind: "rendered_order",
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
status: "Paid",
total: "US $10.00",
image: "https://img.alicdn.com/card.jpg",
};
const observation = {
conversationId: "conversation-1",
@@ -30,28 +25,17 @@ const key = JSON.stringify([
observation.conversationId,
observation.messageId,
]);
const baseEvidence = [
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: "received",
sentAtMs: 99,
},
];
test("writes the card ledger before immediate send, resends after reconnect, and exact-matches ACK", async () => {
const timeline = [];
const records = new Map();
const acknowledgements = [];
const ledger = {
observe: async ({ channelAccountId, observation: value, baseEvidence: evidence }) => {
observe: async ({ channelAccountId, observation: value }) => {
timeline.push("write");
const record = {
key,
channelAccountId,
...value,
baseDirection: evidence.direction,
baseSentAtMs: evidence.sentAtMs,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
@@ -105,7 +89,7 @@ test("writes the card ledger before immediate send, resends after reconnect, and
createRequestId: () => "card-request",
});
await coordinator.observe([observation], baseEvidence);
await coordinator.observe([observation]);
assert.equal(frames.length, 1);
assert.equal(frames[0].type, "rendered.card.observed");
assert.deepEqual(timeline, ["write", "send"]);
@@ -152,8 +136,6 @@ test("recovers a pending record after worker restart without a base candidate",
key,
channelAccountId: scope.channelAccountId,
...observation,
baseDirection: "received",
baseSentAtMs: 99,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
@@ -1,504 +1,191 @@
// 验证渲染卡片 observer 在消息列表异步挂载时仍能建立监听。
// 验证 DOM card observer 只扫描渲染卡片并在页面关闭时释放监听。
import assert from "node:assert/strict";
import test from "node:test";
import { installOneTalkRenderedCardObserver } from "../src/onetalk/main-page/card-observer/entry.ts";
test("installs a mutation observer before the first message wrapper exists", () => {
const cardFor = () => {
const wrapper = {
getAttribute: () =>
JSON.stringify({ messageId: "message-1", conversationCode: "conversation-1" }),
};
return {
textNodes: [{ nodeValue: "Order" }, { nodeValue: "Widget" }],
getAttribute: () =>
JSON.stringify({
messageId: "message-1",
conversationCode: "conversation-1",
cardType: 54,
messageType: 1,
}),
closest: () => wrapper,
matches: (selector) =>
selector === ".session-rich-content.card" || selector === "[data-expinfo]",
querySelector: (selector) => {
if (selector === "[data-expinfo]") return card;
return null;
},
querySelectorAll: (selector) =>
selector === "img[src]"
? [
{
getAttribute: (name) =>
name === "src" ? "https://img.alicdn.com/card.jpg" : null,
},
]
: [],
};
};
const card = cardFor();
const fixtureNode = ({ text = null, imageUrl = null, classNames = [] } = {}) => {
const value = {
childNodes: [],
textContent: text,
imageUrl,
classNames: new Set(classNames),
parentElement: null,
getAttribute(name) {
return name === "src" ? this.imageUrl : null;
},
querySelectorAll: (selector) => {
if (selector.startsWith(".")) return [];
assert.equal(selector, "img[src]");
return [];
},
};
return value;
};
const validCardForMutation = () => {
const root = fixtureNode();
const labelNode = fixtureNode({ text: "订单状态", classNames: ["TextView"] });
const statusNode = fixtureNode({ text: "Pending", classNames: ["TextView"] });
const row = fixtureNode();
row.childNodes.push(labelNode, statusNode);
labelNode.parentElement = row;
statusNode.parentElement = row;
root.childNodes.push(row);
row.parentElement = root;
const wrapper = {
getAttribute: () =>
JSON.stringify({ messageId: "message-1", conversationCode: "conversation-1" }),
};
const card = {
closest: () => wrapper,
matches: (selector) =>
selector === ".session-rich-content.card" || selector === "[data-expinfo]",
getAttribute: (name) =>
name === "data-expinfo"
? JSON.stringify({ messageId: "message-1", conversationCode: "conversation-1" })
: null,
querySelector: (selector) => {
if (selector === "[data-expinfo]") return card;
if (selector === ".tpl-wrapper") return root;
return null;
},
};
root.parentElement = card;
statusNode.parentElement = card;
return { card, statusNode };
};
test("rejects unrecognized cards while observing mounts without timers or wrapper scans", () => {
const observers = [];
const listeners = new Map();
const documentElement = {};
const page = {
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
document: {
documentElement,
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [];
if (selector === ".contact-item-container.selected[data-cid]")
return [{ getAttribute: () => "conversation-1" }];
if (selector === ".session-rich-content.card") return [card];
assert.fail(`unexpected selector: ${selector}`);
},
createTreeWalker: (target) => {
let index = 0;
return { nextNode: () => target.textNodes[index++] ?? null };
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
this.targets = [];
this.options = null;
observers.push(this);
}
observe(target) {
this.targets.push(target);
observe(target, options) {
assert.equal(target, documentElement);
this.options = options;
}
disconnect() {
this.disconnected = true;
}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
const observed = [];
installOneTalkRenderedCardObserver(page, (observations) => observed.push(...observations));
installOneTalkRenderedCardObserver(page, () => undefined);
assert.equal(observers.length, 1);
assert.equal(observers[0].targets.length, 1);
assert.equal(listeners.has("pagehide"), true);
assert.equal(observed.length, 0);
assert.deepEqual(observers[0].options, {
childList: true,
subtree: true,
characterData: true,
attributes: true,
attributeFilter: ["data-expinfo", "src", "style"],
});
observers[0].callback([{ target: card, addedNodes: [] }]);
assert.equal(
observed.length,
0,
"an unrecognized structure never publishes a fallback snapshot",
);
listeners.get("pagehide")();
assert.equal(observers[0].disconnected, true);
});
test("rebinds the scoped message observer when its list root is replaced", () => {
test("re-reads a recognized DOM field when its text changes in place", () => {
const observers = [];
const listeners = new Map();
const rootParent = { parentElement: null, querySelectorAll: () => [] };
const firstRoot = { parentElement: rootParent, querySelectorAll: () => [] };
const secondRoot = { parentElement: rootParent, querySelectorAll: () => [] };
let wrapper = { parentElement: firstRoot };
const documentElement = {};
const fixture = validCardForMutation();
const page = {
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
getComputedStyle: () => ({ display: "block", visibility: "visible" }),
document: {
documentElement,
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [wrapper];
if (selector === ".contact-item-container.selected[data-cid]")
return [{ getAttribute: () => "conversation-1" }];
if (selector === ".session-rich-content.card") return [fixture.card];
assert.fail(`unexpected selector: ${selector}`);
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
this.targets = [];
this.options = null;
observers.push(this);
}
observe(target) {
this.targets.push(target);
observe(target, options) {
assert.equal(target, documentElement);
this.options = options;
}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
const observed = [];
installOneTalkRenderedCardObserver(page, (observations) => observed.push(...observations));
assert.equal(observed.length, 1);
installOneTalkRenderedCardObserver(page, () => undefined);
assert.ok(observers[0].targets.includes(firstRoot));
fixture.statusNode.textContent = "Updated status";
observers[0].callback([{ target: fixture.statusNode, addedNodes: [] }]);
wrapper = { parentElement: secondRoot };
observers[0].callback([]);
assert.ok(observers[0].targets.includes(secondRoot));
listeners.get("pagehide")();
});
test("reads a card immediately when its wrapper subtree changes", async () => {
const originalElement = globalThis.Element;
class TestElement {
matches() {
return false;
}
closest() {
return this.parentWrapper ?? null;
}
querySelectorAll() {
return [];
}
}
globalThis.Element = TestElement;
try {
const observers = [];
const listeners = new Map();
const templateFiber = { memoizedProps: {} };
const wrapper = new TestElement();
const root = {
parentElement: null,
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [wrapper];
},
contains: (node) => node === wrapper,
};
wrapper.parentElement = root;
wrapper.parentWrapper = wrapper;
wrapper.__reactFiber$fixture = {
memoizedProps: {
itemData: {
messageId: "message-1",
conversationCode: "conversation-1",
messageType: "rec",
sendTime: 1_700_000_000_000,
msgType: 10010,
originalData: { cardType: 9 },
},
},
child: templateFiber,
};
const page = {
document: {
querySelectorAll: (selector) => {
if (selector === ".message-item-wrapper") return [wrapper];
if (selector === ".contact-item-container.selected[data-cid]") {
return [{ getAttribute: () => "conversation-1" }];
}
assert.fail(`unexpected selector: ${selector}`);
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
observers.push(this);
}
observe() {}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
const observations = [];
installOneTalkRenderedCardObserver(page, (next) => observations.push(...next));
templateFiber.memoizedProps = {
data: {
cardTitle: "Order #1",
productInfoList: [
{ productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" },
],
orderStatusText: "Paid",
shouldPayAmount: "$10",
shippingAddress: "Hangzhou",
},
};
observers[0].callback([{ target: wrapper, addedNodes: [new TestElement()] }]);
assert.deepEqual(
observations.map((observation) => observation.content.kind),
["rendered_order"],
);
listeners.get("pagehide")();
} finally {
if (originalElement === undefined) delete globalThis.Element;
else globalThis.Element = originalElement;
}
});
test("re-reads a wrapper immediately when React changes text inside it", async () => {
const originalElement = globalThis.Element;
class TestElement {
matches() {
return false;
}
closest() {
return this.parentWrapper ?? null;
}
querySelectorAll() {
return [];
}
}
globalThis.Element = TestElement;
try {
const observers = [];
const observeCalls = [];
const listeners = new Map();
const templateFiber = { memoizedProps: {} };
const wrapper = new TestElement();
const textNode = { parentElement: wrapper };
const root = {
parentElement: null,
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [wrapper];
},
contains: (node) => node === wrapper || node?.parentElement === wrapper,
};
wrapper.parentElement = root;
wrapper.parentWrapper = wrapper;
wrapper.__reactFiber$fixture = {
memoizedProps: {
itemData: {
messageId: "message-1",
conversationCode: "conversation-1",
messageType: "rec",
sendTime: 1_700_000_000_000,
msgType: 10010,
originalData: { cardType: 9 },
},
},
child: templateFiber,
};
const page = {
document: {
querySelectorAll: (selector) => {
if (selector === ".message-item-wrapper") return [wrapper];
if (selector === ".contact-item-container.selected[data-cid]") {
return [{ getAttribute: () => "conversation-1" }];
}
assert.fail(`unexpected selector: ${selector}`);
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
observers.push(this);
}
observe(target, options) {
observeCalls.push({ target, options });
}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
const observations = [];
installOneTalkRenderedCardObserver(page, (next) => observations.push(...next));
templateFiber.memoizedProps = {
data: {
cardTitle: "Order #1",
productInfoList: [
{ productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" },
],
orderStatusText: "Paid",
shouldPayAmount: "$10",
shippingAddress: "Hangzhou",
},
};
observers[0].callback([{ target: textNode, addedNodes: [] }]);
assert.ok(
observeCalls.some(
({ target, options }) =>
target === root &&
options.attributes === true &&
options.characterData === true,
),
);
assert.deepEqual(
observations.map((observation) => observation.content.kind),
["rendered_order"],
);
listeners.get("pagehide")();
} finally {
if (originalElement === undefined) delete globalThis.Element;
else globalThis.Element = originalElement;
}
});
test("re-reads a virtualized wrapper for a new card but deduplicates the same card fingerprint", async () => {
const originalElement = globalThis.Element;
class TestElement {
matches() {
return false;
}
closest() {
return this.parentWrapper ?? null;
}
querySelectorAll() {
return [];
}
}
globalThis.Element = TestElement;
try {
const observers = [];
const listeners = new Map();
const templateFiber = { memoizedProps: {} };
const wrapper = new TestElement();
const root = {
parentElement: null,
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [wrapper];
},
contains: (node) => node === wrapper,
};
wrapper.parentElement = root;
wrapper.parentWrapper = wrapper;
wrapper.__reactFiber$fixture = {
memoizedProps: {
itemData: {
messageId: "message-1",
conversationCode: "conversation-1",
messageType: "rec",
sendTime: 1_700_000_000_000,
msgType: 10010,
originalData: { cardType: 9 },
},
},
child: templateFiber,
};
const page = {
document: {
querySelectorAll: (selector) => {
if (selector === ".message-item-wrapper") return [wrapper];
if (selector === ".contact-item-container.selected[data-cid]") {
return [{ getAttribute: () => "conversation-1" }];
}
assert.fail(`unexpected selector: ${selector}`);
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
observers.push(this);
}
observe() {}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
const observations = [];
const setOrderTemplate = (title) => {
templateFiber.memoizedProps = {
data: {
cardTitle: title,
productInfoList: [
{ productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" },
],
orderStatusText: "Paid",
shouldPayAmount: "$10",
shippingAddress: "Hangzhou",
},
};
};
setOrderTemplate("Order #1");
installOneTalkRenderedCardObserver(page, (next) => observations.push(...next));
await new Promise((resolve) => setTimeout(resolve, 120));
wrapper.__reactFiber$fixture.memoizedProps.itemData.messageId = "message-2";
setOrderTemplate("Order #2");
observers[0].callback([{ target: wrapper, addedNodes: [new TestElement()] }]);
await new Promise((resolve) => setTimeout(resolve, 120));
observers[0].callback([{ target: wrapper, addedNodes: [new TestElement()] }]);
await new Promise((resolve) => setTimeout(resolve, 120));
assert.deepEqual(
observations.map((value) => [value.messageId, value.content.title]),
[
["message-1", "Order #1"],
["message-2", "Order #2"],
],
);
listeners.get("pagehide")();
} finally {
if (originalElement === undefined) delete globalThis.Element;
else globalThis.Element = originalElement;
}
});
test("rescans the current list every second when a card Fiber becomes ready without a DOM mutation", () => {
const originalElement = globalThis.Element;
const originalSetInterval = globalThis.setInterval;
const originalClearInterval = globalThis.clearInterval;
class TestElement {
matches() {
return false;
}
closest() {
return this.parentWrapper ?? null;
}
querySelectorAll() {
return [];
}
}
globalThis.Element = TestElement;
try {
const observers = [];
const intervals = [];
const clearedIntervals = [];
const listeners = new Map();
const templateFiber = { memoizedProps: {} };
const wrapper = new TestElement();
const root = {
parentElement: null,
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [wrapper];
},
contains: (node) => node === wrapper,
};
wrapper.parentElement = root;
wrapper.parentWrapper = wrapper;
wrapper.__reactFiber$fixture = {
memoizedProps: {
itemData: {
messageId: "message-1",
conversationCode: "conversation-1",
messageType: "rec",
sendTime: 1_700_000_000_000,
msgType: 10010,
originalData: { cardType: 9 },
},
},
child: templateFiber,
};
globalThis.setInterval = (callback, delay) => {
intervals.push({ callback, delay });
return intervals.length;
};
globalThis.clearInterval = (interval) => clearedIntervals.push(interval);
const page = {
document: {
querySelectorAll: (selector) => {
if (selector === ".message-item-wrapper") return [wrapper];
if (selector === ".contact-item-container.selected[data-cid]") {
return [{ getAttribute: () => "conversation-1" }];
}
assert.fail(`unexpected selector: ${selector}`);
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
observers.push(this);
}
observe() {}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
const observations = [];
installOneTalkRenderedCardObserver(page, (next) => observations.push(...next));
assert.deepEqual(
intervals.map((interval) => interval.delay),
[1_000],
);
templateFiber.memoizedProps = {
data: {
cardTitle: "Order #1",
productInfoList: [
{ productName: "Widget", productImage: "https://img.alicdn.com/item.jpg" },
],
orderStatusText: "Paid",
shouldPayAmount: "$10",
shippingAddress: "Hangzhou",
},
};
intervals[0].callback();
assert.deepEqual(
observations.map((observation) => observation.content.kind),
["rendered_order"],
);
listeners.get("pagehide")();
assert.deepEqual(clearedIntervals, [1]);
} finally {
if (originalElement === undefined) delete globalThis.Element;
else globalThis.Element = originalElement;
globalThis.setInterval = originalSetInterval;
globalThis.clearInterval = originalClearInterval;
}
assert.equal(observed.length, 2);
assert.equal(observed[1].content.status, "Updated status");
});
@@ -1,13 +1,71 @@
// 验证 MAIN-world React Fiber reader 只投影订单、询盘与商品的批准字段
// 验证 DOM-only reader 以可见结构采集命名字段,而不是以模板 ID 准入
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkRenderedCardContentFingerprint } from "@trade-message-center/onetalk-contract";
import { readOneTalkRenderedCard } from "../src/onetalk/main-page/card-observer/react-card-reader.ts";
import { readOneTalkRenderedCardFromDom } from "../src/onetalk/main-page/card-observer/dom-card-reader.ts";
const imageUrl = "https://img.alicdn.com/card-product.jpg";
const productUrl = "https://chinese.alibaba.com/product-detail/widget-12345.html";
const node = ({
text = null,
imageUrl = null,
viewName = null,
classNames = [],
children = [],
} = {}) => {
const value = {
childNodes: children,
textContent: text,
parentElement: null,
classNames: new Set(classNames),
getAttribute: (name) => {
if (name === "src") return imageUrl;
if (name === "view-name") return viewName;
return null;
},
querySelectorAll(selector) {
const matches = [];
const visit = (candidate) => {
if (
(selector === "img[src]" && candidate.getAttribute("src")) ||
(selector.startsWith(".") && candidate.classNames?.has(selector.slice(1)))
)
matches.push(candidate);
for (const child of candidate.childNodes ?? []) visit(child);
};
for (const child of this.childNodes) visit(child);
return matches;
},
};
for (const child of children) child.parentElement = value;
return value;
};
const fieldRow = (label, value) => {
const labelNode = node({ text: label, viewName: "TextView" });
const valueNode = node({ text: value, viewName: "TextView" });
return node({ children: [labelNode, valueNode] });
};
const productStructure = () => {
const minimumOrder = node({
children: [
node({ text: "Minimum order", viewName: "TextView" }),
node({ text: "500", viewName: "TextView" }),
node({ text: "pieces", viewName: "TextView" }),
],
});
const productOwner = node({
classNames: ["dx-event-node"],
children: [
node({ text: "Product title", viewName: "TextView" }),
node({ text: "US $10.00", viewName: "TextView" }),
minimumOrder,
node({ imageUrl: "https://img.alicdn.com/product.jpg" }),
],
});
return node({ classNames: ["tpl-wrapper"], children: [productOwner] });
};
const pageFor = ({ conversationId = "conversation-1", selectionReads = [conversationId] } = {}) => {
let reads = 0;
@@ -18,351 +76,320 @@ const pageFor = ({ conversationId = "conversation-1", selectionReads = [conversa
assert.equal(selector, ".contact-item-container.selected[data-cid]");
const selected = selectionReads[Math.min(reads, selectionReads.length - 1)];
reads += 1;
return selected
? [{ getAttribute: (name) => (name === "data-cid" ? selected : null) }]
: [];
return selected ? [{ getAttribute: () => selected }] : [];
},
},
getComputedStyle: () => ({ display: "block", visibility: "visible" }),
};
};
const itemFor = (overrides = {}) => ({
messageId: "message-1",
conversationCode: "conversation-1",
messageType: "rec",
sendTime: 1_700_000_000_000,
msgType: 10010,
originalData: { cardType: 9 },
...overrides,
});
const wrapperFor = ({ item = itemFor(), data, cardType } = {}) => {
const templateFiber = {
memoizedProps: {
...(cardType === undefined ? {} : { cardType }),
...(data === undefined ? {} : { data }),
const cardFor = (
root,
{ cardInfo = { messageId: "message-1" }, wrapperInfo = { messageId: "message-1" } } = {},
) => {
const wrapper = { getAttribute: () => JSON.stringify(wrapperInfo) };
const card = {
closest: (selector) => {
assert.equal(selector, ".message-item-wrapper[data-expinfo]");
return wrapper;
},
};
const fiber = { memoizedProps: { itemData: item }, child: templateFiber };
return {
__reactFiber$fixture: fiber,
querySelectorAll: (selector) => {
assert.equal(selector, "*");
return [];
matches: (selector) => selector === "[data-expinfo]",
getAttribute: (name) => (name === "data-expinfo" ? JSON.stringify(cardInfo) : null),
querySelector: (selector) => {
if (selector === "[data-expinfo]") return card;
if (selector === ".tpl-wrapper") return root;
assert.fail(`unexpected selector: ${selector}`);
},
querySelectorAll: root.querySelectorAll.bind(root),
childNodes: root.childNodes,
};
root.parentElement = card;
return card;
};
const readWithFixedTime = (page, wrapper) => {
const orderVariant = ({ templateId = null } = {}) => {
const product = node({
classNames: ["list-item"],
children: [node({ imageUrl: "https://img.alicdn.com/order.jpg" })],
});
const root = node({
classNames: ["tpl-wrapper"],
children: [
node({ text: "订单", viewName: "TextView" }),
product,
fieldRow("Product count", "3 items"),
fieldRow("订单状态", "Paid"),
fieldRow("Total", "US $10.00"),
fieldRow("Delivery date", "2026-09-16"),
fieldRow("Shipping address", "1 Market Street"),
fieldRow("Colour", "Blue"),
],
});
root.getAttribute = (name) => (name === "data-tpl-id" ? templateId : null);
return root;
};
const readWithFixedTime = (page, card) => {
const previousNow = Date.now;
Date.now = () => 1_700_000_000_123;
try {
return readOneTalkRenderedCard(page, wrapper);
return readOneTalkRenderedCardFromDom(page, card);
} finally {
Date.now = previousNow;
}
};
test("reads an order that uses productName into the exact normalized card and base evidence", () => {
test("collects a named order structure without using its unknown template ID", () => {
const content = {
version: 1,
kind: "rendered_order",
title: "Order #1",
products: [{ title: "Widget", imageUrl }],
productCount: 1,
status: { code: "9", text: "Paid" },
payment: { totalDisplay: "$10", discountDisplay: "$1" },
delivery: { shippingAddress: "Hangzhou", methodLabel: "Express", dateLabel: "Tomorrow" },
action: { label: "View order", status: "available" },
image: "https://img.alicdn.com/order.jpg",
productCount: { display: "3 items", value: 3 },
status: "Paid",
total: "US $10.00",
deliveryDate: "2026-09-16",
shippingAddress: "1 Market Street",
};
const read = readWithFixedTime(
const result = readWithFixedTime(
pageFor(),
wrapperFor({
data: {
cardTitle: content.title,
productInfoList: [{ productName: "Widget", productImage: imageUrl }],
productCount: 1,
orderStatus: 9,
orderStatusText: "Paid",
shouldPayAmount: "$10",
discountAmount: "$1",
shippingAddress: "Hangzhou",
shippingMethodText: "Express",
deliveryTimeText: "Tomorrow",
orderAction: { label: "View order", status: "available" },
},
}),
cardFor(orderVariant({ templateId: "3829708538501" })),
);
assert.deepEqual(read, {
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_123,
},
baseEvidence: {
conversationId: "conversation-1",
messageId: "message-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
},
assert.deepEqual(result, {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_123,
});
});
test("reads an inquiry into only the approved product, requirement, reference, and ordered action fields", () => {
const content = {
version: 1,
kind: "rendered_inquiry",
product: { title: "Widget", imageUrl },
purchaseQuantity: { value: "100", unit: "pieces" },
requirementText: "Need 100 widgets\nSecond line",
inquiryReference: "inquiry-1",
actions: [
{ label: "Details", available: true },
{ label: "Quote", available: false },
{ label: "Send inquiry", available: true },
],
};
const read = readWithFixedTime(
pageFor(),
wrapperFor({
item: itemFor({ originalData: { cardType: 6 } }),
data: {
inquiryCardDTO: {
summary: "Need 100 widgets\nSecond line",
unapproved: "must not cross",
},
displayProducts: [
{
productName: "Widget",
productImage: imageUrl,
productMOQ: "100",
productUnit: "pieces",
hiddenProductField: "must not cross",
},
test("collects an order image beside a direct product-count display without list-item markup", () => {
const root = node({
classNames: ["tpl-wrapper"],
children: [
node({ text: "订单", viewName: "TextView" }),
node({ imageUrl: "https://img.alicdn.com/order-chevron.jpg" }),
node({
children: [
node({
children: [
node({ imageUrl: "https://img.alicdn.com/order-product.jpg" }),
node({ imageUrl: "https://img.alicdn.com/order-product-second.jpg" }),
],
}),
node({
children: [
node({
children: [
node({
text: "5 products",
viewName: "TextView",
}),
],
}),
],
}),
],
inquiryContent: "Need 100 widgets\nSecond line",
inquiryID: "inquiry-1",
detailStr: "Details",
detailAction: { params: "must not cross" },
quoteStr: "Quote",
quoteAction: null,
actionName: "Send inquiry",
inquiryAction: { callback: "must not cross" },
},
}),
);
assert.deepEqual(read, {
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_123,
},
baseEvidence: {
conversationId: "conversation-1",
messageId: "message-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
},
}),
fieldRow("订单状态", "Pending"),
],
});
});
test("reads a product only through the product renderer and omits every raw or store-image field", () => {
const content = {
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, {
version: 1,
kind: "rendered_product",
product: {
title: "Widget",
imageUrl,
sourceUrl: productUrl,
productId: "12345",
},
priceDisplay: "$10",
minimumOrder: { value: "2", unit: "pieces" },
serviceBadges: ["Fast delivery", "Buyer protection"],
};
const read = readWithFixedTime(
pageFor(),
wrapperFor({
item: itemFor({ msgType: 101, originalData: {} }),
cardType: 54,
data: {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: { actionParams: { url: productUrl, unsafe: "must not cross" } },
benefitFirstText: "Fast delivery",
benefitSecondText: "Fast delivery",
promotionTag: "Buyer protection",
hsfImg: "https://img.alicdn.com/store.jpg",
storeImageUrl: "https://img.alicdn.com/store.jpg",
traceInfo: { unsafe: "must not cross" },
},
}),
);
assert.deepEqual(read, {
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_123,
},
baseEvidence: {
conversationId: "conversation-1",
messageId: "message-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
},
image: "https://img.alicdn.com/order-product.jpg",
productCount: { display: "5 products", value: 5 },
status: "Pending",
});
assert.deepEqual(Object.keys(read?.observation.content.product ?? {}).sort(), [
"imageUrl",
"productId",
"sourceUrl",
"title",
]);
for (const forbidden of ["storeImageUrl", "hsfImg", "traceInfo", "actionParams", "data"]) {
assert.equal(forbidden in (read?.observation.content.product ?? {}), false);
assert.equal(forbidden in (read?.observation.content ?? {}), false);
}
});
test("rejects files, lookalikes, incomplete templates and identity or session changes", () => {
const orderData = {
cardTitle: "Order #1",
productInfoList: [{ productName: "Widget", productImage: imageUrl }],
orderStatusText: "Paid",
shouldPayAmount: "$10",
shippingAddress: "Hangzhou",
};
const cases = [
{
name: "file card",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ originalData: { cardType: 12 } }),
data: orderData,
}),
},
{
name: "unknown 10010 card",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ originalData: { cardType: 7 } }),
data: orderData,
}),
},
{
name: "non-product 101 card",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ msgType: 101, originalData: {} }),
data: orderData,
}),
},
{
name: "missing template field",
page: pageFor(),
wrapper: wrapperFor({ data: { ...orderData, shippingAddress: undefined } }),
},
{
name: "unsafe image URL",
page: pageFor(),
wrapper: wrapperFor({
data: {
...orderData,
productInfoList: [
{ productName: "Widget", productImage: "https://evil.example/image.jpg" },
],
},
}),
},
{
name: "unsafe product reference",
page: pageFor(),
wrapper: wrapperFor({
item: itemFor({ msgType: 101, originalData: {} }),
cardType: 54,
data: {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: {
actionParams: { url: "https://evil.example/widget-12345.html" },
},
},
}),
},
{
name: "incomplete identity",
page: pageFor(),
wrapper: wrapperFor({ item: itemFor({ messageId: "" }), data: orderData }),
},
{
name: "selection changes while reading",
page: pageFor({ selectionReads: ["conversation-1", "conversation-2"] }),
wrapper: wrapperFor({ data: orderData }),
},
];
for (const entry of cases) {
assert.equal(readWithFixedTime(entry.page, entry.wrapper), null, entry.name);
}
test("omits unrecognized inquiry rows instead of emitting attributes", () => {
const root = node({
classNames: ["tpl-wrapper"],
children: [
fieldRow("Colour", "Blue"),
node({ text: "详细需求", viewName: "TextView" }),
node({ classNames: ["description-container"], text: "Need delivery this week" }),
node({ classNames: ["dx-event-node"], text: "View details" }),
],
});
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, {
version: 1,
requirementText: "Need delivery this week",
});
});
test("does not classify a product renderer beyond the bounded Fiber subtree scan", () => {
const data = {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: { actionParams: { url: productUrl } },
};
const fiber = {
memoizedProps: { itemData: itemFor({ msgType: 101, originalData: {} }), data },
};
let cursor = fiber;
for (let index = 0; index < 120; index += 1) {
cursor.sibling = { memoizedProps: {} };
cursor = cursor.sibling;
}
cursor.memoizedProps.cardType = 54;
const wrapper = {
__reactFiber$fixture: fiber,
querySelectorAll: () => [],
};
assert.equal(readWithFixedTime(pageFor(), wrapper), null);
test("collects an inquiry product, purchase quantity, and requirement without its UI ID", () => {
const root = node({
classNames: ["tpl-wrapper"],
children: [
node({
classNames: ["list-item"],
children: [
node({ imageUrl: "https://img.alicdn.com/inquiry-product.jpg" }),
node({ text: "Inquiry product title", viewName: "TextView" }),
],
}),
fieldRow("采购数量", "500 Piece/Pieces"),
node({ text: "详细需求", viewName: "TextView" }),
node({
classNames: ["description-container"],
text: "Need custom sizing\nSecond line",
}),
fieldRow("ID:", "13894257698"),
],
});
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, {
version: 1,
title: "Inquiry product title",
image: "https://img.alicdn.com/inquiry-product.jpg",
purchaseQuantity: "500 Piece/Pieces",
requirementText: "Need custom sizing\nSecond line",
});
});
test("does not borrow a product renderer from the sibling of the current message root", () => {
const productData = {
title: "Widget",
productImage: imageUrl,
price: "$10",
moq: "2",
moqUnit: "pieces",
productAction: { actionParams: { url: productUrl } },
};
const rootFiber = {
memoizedProps: { itemData: itemFor({ msgType: 101, originalData: {} }) },
sibling: { memoizedProps: { cardType: 54, data: productData } },
};
const wrapper = {
__reactFiber$fixture: rootFiber,
querySelectorAll: () => [],
};
assert.equal(readWithFixedTime(pageFor(), wrapper), null);
test("preserves rendered line breaks in an inquiry requirement", () => {
const requirement = node({
classNames: ["description-container"],
text: "Need custom sizingSecond line",
});
requirement.innerText = "Need custom sizing\nSecond line";
const root = node({
classNames: ["tpl-wrapper"],
children: [node({ text: "详细需求", viewName: "TextView" }), requirement],
});
assert.equal(
readWithFixedTime(pageFor(), cardFor(root))?.content.requirementText,
"Need custom sizing\nSecond line",
);
});
test("rejects an inquiry-shaped card without the detailed-requirement anchor", () => {
const root = node({
classNames: ["tpl-wrapper"],
children: [
fieldRow("Colour", "Blue"),
node({ classNames: ["description-container"], text: "Need delivery this week" }),
],
});
assert.equal(readWithFixedTime(pageFor(), cardFor(root)), null);
});
test("rejects a card that matches more than one parser anchor", () => {
const product = productStructure();
const root = node({
classNames: ["tpl-wrapper"],
children: [...product.childNodes, fieldRow("订单状态", "Pending")],
});
assert.equal(readWithFixedTime(pageFor(), cardFor(root)), null);
});
test("rejects cards with a repeated parser anchor", () => {
const order = orderVariant();
order.childNodes.push(fieldRow("订单状态", "Duplicate"));
assert.equal(readWithFixedTime(pageFor(), cardFor(order)), null);
const inquiry = node({
classNames: ["tpl-wrapper"],
children: [
node({ text: "详细需求", viewName: "TextView" }),
node({ text: "详细需求", viewName: "TextView" }),
node({ classNames: ["description-container"], text: "Need details" }),
],
});
assert.equal(readWithFixedTime(pageFor(), cardFor(inquiry)), null);
const product = productStructure();
product.childNodes.push(node({ text: "Minimum order", viewName: "TextView" }));
assert.equal(readWithFixedTime(pageFor(), cardFor(product)), null);
});
test("collects a product owner with view-name TextViews and no template metadata", () => {
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(productStructure()))?.content, {
version: 1,
title: "Product title",
price: "US $10.00",
minimumOrder: "500 pieces",
image: "https://img.alicdn.com/product.jpg",
});
});
test("collects a nested product variant around its minimum-order anchor", () => {
const textContainer = (text) =>
node({
children: [node({ text, viewName: "TextView" })],
});
const root = node({
classNames: ["tpl-wrapper"],
children: [
node({
classNames: ["dx-event-node"],
children: [
node({ imageUrl: "https://img.alicdn.com/variant-product.jpg" }),
textContainer("Super"),
textContainer("Variant product title"),
textContainer("$4.50-5.09"),
node({
children: [
node({ text: "最小订购量", viewName: "TextView" }),
node({ text: "10", viewName: "TextView" }),
node({ text: "Pieces", viewName: "TextView" }),
],
}),
textContainer("无忧退货"),
],
}),
],
});
assert.deepEqual(readWithFixedTime(pageFor(), cardFor(root))?.content, {
version: 1,
title: "Variant product title",
price: "$4.50-5.09",
minimumOrder: "10 Pieces",
image: "https://img.alicdn.com/variant-product.jpg",
});
});
test("does not project unrecognized non-inquiry rows or action labels", () => {
const root = node({
classNames: ["tpl-wrapper"],
children: [
fieldRow(
"Women's Elegant Fashionable PU Leather Crossbody Bag",
"$4.50-5.09最小订购量: 10Pieces无忧退货",
),
node({ classNames: ["dx-event-node"], text: "View details" }),
],
});
assert.equal(readWithFixedTime(pageFor(), cardFor(root)), null);
});
test("rejects identity conflicts, unsafe images, invisible nodes, unstable selection, and empty structures", () => {
assert.equal(
readWithFixedTime(pageFor(), cardFor(node({ classNames: ["tpl-wrapper"] }))),
null,
);
assert.equal(
readWithFixedTime(
pageFor(),
cardFor(orderVariant(), { wrapperInfo: { messageId: "other" } }),
),
null,
);
const unsafe = orderVariant();
unsafe.childNodes[1].childNodes[0].getAttribute = (name) =>
name === "src" ? "https://img.alicdn.com/order.jpg?token=secret" : null;
assert.equal(
readWithFixedTime(pageFor(), cardFor(unsafe))?.content.image,
undefined,
"an unsafe optional image group must not suppress recognized text fields",
);
const hidden = orderVariant();
hidden.childNodes[3].childNodes[1].hidden = true;
assert.equal(readWithFixedTime(pageFor(), cardFor(hidden))?.content.status, undefined);
assert.equal(
readWithFixedTime(
pageFor({ selectionReads: ["conversation-1", "conversation-2"] }),
cardFor(orderVariant()),
),
null,
);
});
@@ -121,7 +121,7 @@ test("matches a .PNM SDK send result to its canonical observed message ID", asyn
assert.equal((await resultPromise).status, "confirmed_sent");
});
test("uses a five-second window and never confirms a pre-send observation", async () => {
test("accepts earlier observed timestamps while keeping the five-second future window", async () => {
const beforeSend = createSendObservationCorrelator(20);
const beforeSendResult = beforeSend.execute(
"conversation-1",
@@ -131,10 +131,7 @@ test("uses a five-second window and never confirms a pre-send observation", asyn
);
await new Promise((resolve) => setImmediate(resolve));
beforeSend.observe([completeSent({ sentAtMs: Date.now() - 1_000 })]);
assert.deepEqual(await beforeSendResult, {
status: "delivery_unknown",
reason: "send_state_lost",
});
assert.equal((await beforeSendResult).status, "confirmed_sent");
const outsideWindow = createSendObservationCorrelator(20);
const outsideWindowResult = outsideWindow.execute(
@@ -451,8 +451,6 @@ test("preserves v9 rendered-card records and reads pending ACKs through the acco
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 100,
baseDirection: "received",
baseSentAtMs: 99,
status: "pending_ack",
firstObservedAt: 100,
updatedAt: 100,
@@ -506,7 +504,6 @@ test("clears only one conversation history ledger after its transaction commits"
contentFingerprint: createOneTalkRenderedCardContentFingerprint(renderedCardContent),
observedAtMs: 500,
},
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 500,
});
for (const [channelAccountId, conversationId, messageId] of [
@@ -523,7 +520,6 @@ test("clears only one conversation history ledger after its transaction commits"
createOneTalkRenderedCardContentFingerprint(renderedCardContent),
observedAtMs: 500,
},
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 500,
});
}
@@ -643,20 +639,19 @@ test("preserves the first rendered-card snapshot across concurrent same-key obse
store.observe({
channelAccountId: "account-1",
observation: first,
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 500,
}),
store.observe({
channelAccountId: "account-1",
observation: second,
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 501,
}),
]);
const record = await store.get("account-1", "conversation-1", "message-1");
assert.deepEqual(record.content, firstContent);
assert.equal(record.lastConflictingFingerprint, second.contentFingerprint);
assert.equal("baseDirection" in record, false);
assert.equal("baseSentAtMs" in record, false);
});
test("keeps the first pending ACK correlation while serializing same-key ledger writes", async () => {
@@ -681,8 +676,7 @@ test("keeps the first pending ACK correlation while serializing same-key ledger
observedAtMs: 500,
};
const second = { ...first, observedAtMs: 501 };
const baseEvidence = { direction: "received", sentAtMs: 499 };
await store.observe({ channelAccountId: "account-1", observation: first, baseEvidence });
await store.observe({ channelAccountId: "account-1", observation: first });
await store.markSent({
channelAccountId: "account-1",
conversationId: first.conversationId,
@@ -692,7 +686,7 @@ test("keeps the first pending ACK correlation while serializing same-key ledger
requestId: "request-1",
});
await Promise.all([
store.observe({ channelAccountId: "account-1", observation: second, baseEvidence }),
store.observe({ channelAccountId: "account-1", observation: second }),
store.markAcknowledged({
channelAccountId: "account-1",
conversationId: first.conversationId,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/mind-test-harness",
"version": "0.8.27",
"version": "0.8.28",
"private": true,
"type": "module",
"scripts": {
@@ -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')));
+951
View File
@@ -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": {}
}
}
+951
View File
@@ -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": {}
}
}
+14
View File
@@ -106,6 +106,20 @@
"when": 1789399393296,
"tag": "0014_youthful_iron_lad",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1789532281000,
"tag": "0015_wild_toad_men",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789534247470,
"tag": "0016_optimal_imperial_guard",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/server",
"version": "0.8.27",
"version": "0.8.28",
"private": true,
"type": "module",
"scripts": {
+2 -14
View File
@@ -124,7 +124,7 @@ export const onetalkMessage = pgTable(
],
);
/** 与基础消息分离的只读 React 渲染卡片补全事实。 */
/** 与基础消息分离的只读 DOM 渲染卡片补全事实。 */
export const onetalkRenderedCardContent = pgTable(
"onetalk_rendered_card_content",
{
@@ -144,20 +144,8 @@ export const onetalkRenderedCardContent = pgTable(
renderedCardObservedAtMs: bigint("rendered_card_observed_at_ms", {
mode: "number",
}).notNull(),
/** 首次接受该补全事实的服务端审计时间。 */
firstConfirmedAt: timestamp("first_confirmed_at", { withTimezone: true })
.defaultNow()
.notNull(),
/** 最近一次收到同一补全事实的服务端审计时间。 */
lastObservedAt: timestamp("last_observed_at", { withTimezone: true })
.defaultNow()
.notNull(),
/** 不同内容尝试的次数;首个接受快照不可覆盖。 */
conflictCount: integer("conflict_count").notNull().default(0),
/** 最近一次冲突补全的指纹,仅用于诊断。 */
lastConflictingFingerprint: text("last_conflicting_fingerprint"),
/** 最近一次冲突 observation 的页面时间。 */
lastConflictObservedAtMs: bigint("last_conflict_observed_at_ms", { mode: "number" }),
},
(table) => [
primaryKey({ columns: [table.channelAccountId, table.conversationId, table.messageId] }),
@@ -168,7 +156,7 @@ export const onetalkRenderedCardContent = pgTable(
),
check(
"onetalk_rendered_card_content_v1_chk",
sql`jsonb_typeof(${table.renderedCardContent}) = 'object' and (${table.renderedCardContent} ->> 'version') = '1' and (${table.renderedCardContent} ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order')`,
sql`jsonb_typeof(${table.renderedCardContent}) = 'object' and (${table.renderedCardContent} ->> 'version') = '1' and ((${table.renderedCardContent} ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order') or not (${table.renderedCardContent} ? 'kind'))`,
),
],
);
@@ -4,11 +4,13 @@ import {
isOneTalkCenterMessageContent,
isOneTalkMessageContent,
isOneTalkRenderedCardContent,
isOneTalkRenderedCard,
type OneTalkHttpConversation,
type OneTalkCenterMessage,
type OneTalkMessage,
type OneTalkCenterMessageContent,
type OneTalkRenderedCardContent,
type OneTalkRenderedCard,
} from "@trade-message-center/onetalk-contract";
import type {
@@ -108,6 +110,7 @@ const supplementMessageContent = (
if (!isOneTalkRenderedCardContent(renderedCardContent)) {
throw new Error("Invalid persisted OneTalk rendered-card supplement");
}
if (isOneTalkRenderedCard(renderedCardContent)) return content;
if (renderedCardContent.kind === "rendered_inquiry") {
if (content.kind !== "inquiry") {
throw new Error("OneTalk rendered-card supplement kind does not match base message");
@@ -157,6 +160,17 @@ const supplementMessageContent = (
};
};
const renderedCardFor = (
renderedCardContent: OneTalkRenderedCardContent | undefined,
): OneTalkRenderedCard | undefined => {
if (renderedCardContent === undefined) return undefined;
if (!isOneTalkRenderedCardContent(renderedCardContent)) {
throw new Error("Invalid persisted OneTalk rendered-card supplement");
}
if (!isOneTalkRenderedCard(renderedCardContent)) return undefined;
return structuredClone(renderedCardContent);
};
const projectMessageContent = (
content: OneTalkCenterMessageContent,
customerProfile?: Pick<
@@ -184,6 +198,7 @@ export const toOneTalkCenterMessage = (
>,
): OneTalkCenterMessage => {
const content = supplementMessageContent(message.content, renderedCardContent);
const renderedCard = renderedCardFor(renderedCardContent);
if (!isOneTalkCenterMessageContent(content)) {
throw new Error("Invalid persisted OneTalk message content");
}
@@ -195,6 +210,7 @@ export const toOneTalkCenterMessage = (
direction: message.direction,
sentAtMs: message.sentAtMs,
content: projectMessageContent(content, customerProfile),
...(renderedCard === undefined ? {} : { renderedCard }),
};
};
@@ -28,7 +28,6 @@ export type OneTalkRenderedCardRepository = {
store: (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
receivedAt: Date;
commitGuard?: OneTalkCommitGuard;
}) => Promise<OneTalkRenderedCardStoreResult>;
};
@@ -79,8 +79,6 @@ const store = async (
renderedCardContent: input.observation.content,
renderedCardContentFingerprint: input.observation.contentFingerprint,
renderedCardObservedAtMs: input.observation.observedAtMs,
firstConfirmedAt: input.receivedAt,
lastObservedAt: input.receivedAt,
});
input.commitGuard?.assertValid();
const baseMessage = await existingBaseMessageFor(transaction, input);
@@ -93,10 +91,6 @@ const store = async (
}
const saved = record.renderedCardContent as OneTalkRenderedCardContent;
if (isSameOneTalkRenderedCardContent(saved, input.observation.content)) {
await transaction
.update(onetalkRenderedCardContent)
.set({ lastObservedAt: input.receivedAt })
.where(keyCondition(input));
input.commitGuard?.assertValid();
const baseMessage = await existingBaseMessageFor(transaction, input);
input.commitGuard?.assertValid();
@@ -108,11 +102,7 @@ const store = async (
}
await transaction
.update(onetalkRenderedCardContent)
.set({
conflictCount: sql`${onetalkRenderedCardContent.conflictCount} + 1`,
lastConflictingFingerprint: input.observation.contentFingerprint,
lastConflictObservedAtMs: input.observation.observedAtMs,
})
.set({ conflictCount: sql`${onetalkRenderedCardContent.conflictCount} + 1` })
.where(keyCondition(input));
input.commitGuard?.assertValid();
const baseMessage = await existingBaseMessageFor(transaction, input);
@@ -1,7 +1,7 @@
// 编排 OneTalk 渲染卡片补全的受控写入
import {
cloneOneTalkRenderedCardContent,
cloneOneTalkRenderedCard,
createOneTalkRenderedCardContentFingerprint,
} from "@trade-message-center/onetalk-contract";
import type {
@@ -12,10 +12,9 @@ import type {
/** 创建仅提交独立 supplement 事实的服务。 */
export const createOneTalkRenderedCardService = (
repository: OneTalkRenderedCardRepository,
now: () => Date = () => new Date(),
): OneTalkRenderedCardService => ({
ingest: async (input) => {
const content = cloneOneTalkRenderedCardContent(input.observation.content);
const content = cloneOneTalkRenderedCard(input.observation.content);
return repository.store({
channelAccountId: input.channelAccountId,
observation: {
@@ -23,7 +22,6 @@ export const createOneTalkRenderedCardService = (
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
},
receivedAt: now(),
commitGuard: input.commitGuard,
});
},
@@ -1,13 +1,22 @@
// 验证 OneTalk 名片资料从消息 JSONB 中移除的迁移边界
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { migrationDirectory } from "../src/database/migration-config.ts";
const migrationPath = resolve(process.cwd(), migrationDirectory, "0011_mushy_baron_strucker.sql");
const migrationPathUrl = new URL(
`../${migrationDirectory}/0011_mushy_baron_strucker.sql`,
import.meta.url,
);
const migrationPath = fileURLToPath(
existsSync(migrationPathUrl)
? migrationPathUrl
: new URL(`../../${migrationDirectory}/0011_mushy_baron_strucker.sql`, import.meta.url),
);
test("normalizes every persisted business-card payload before enforcing the marker-only check", async () => {
const sql = await readFile(migrationPath, "utf8");
@@ -1,16 +1,21 @@
// 验证 OneTalk 图片宽高数据迁移的结构边界
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { migrationDirectory } from "../src/database/migration-config.ts";
const migrationPath = resolve(
process.cwd(),
migrationDirectory,
"0009_remove_image_dimensions.sql",
const migrationPathUrl = new URL(
`../${migrationDirectory}/0009_remove_image_dimensions.sql`,
import.meta.url,
);
const migrationPath = fileURLToPath(
existsSync(migrationPathUrl)
? migrationPathUrl
: new URL(`../../${migrationDirectory}/0009_remove_image_dimensions.sql`, import.meta.url),
);
test("keeps the image-dimensions migration as one breakpoint-delimited data statement", async () => {
@@ -1,13 +1,22 @@
// 验证 OneTalk 商品内容只扩展 JSONB kind 约束,不重写既有事实。
import assert from "node:assert/strict";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import test from "node:test";
import { migrationDirectory } from "../src/database/migration-config.ts";
const migrationPath = resolve(process.cwd(), migrationDirectory, "0013_nifty_thunderbird.sql");
const migrationPathUrl = new URL(
`../${migrationDirectory}/0013_nifty_thunderbird.sql`,
import.meta.url,
);
const migrationPath = fileURLToPath(
existsSync(migrationPathUrl)
? migrationPathUrl
: new URL(`../../${migrationDirectory}/0013_nifty_thunderbird.sql`, import.meta.url),
);
test("extends only the content-kind check with product while preserving existing constraints", async () => {
const sql = await readFile(migrationPath, "utf8");
@@ -666,6 +666,19 @@ test("projects inquiry, product, and order supplements without changing their ba
}
});
test("attaches a generic DOM card beside an unchanged base message", () => {
const renderedCard = {
version: 1 as const,
status: "Paid",
total: "US $10.00",
image: "https://img.alicdn.com/card.jpg",
};
const result = toOneTalkCenterMessage(message(), renderedCard);
assert.deepEqual(result.content, message().content);
assert.deepEqual(result.renderedCard, renderedCard);
});
test("rejects a rendered-card supplement with a different base kind or product identity", () => {
const product = {
version: 1 as const,
@@ -20,14 +20,9 @@ const context = {
};
const content = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
status: "Paid",
total: "US $10.00",
image: "https://img.alicdn.com/card.jpg",
};
const frame = createOneTalkRenderedCardObservedFrame(
{ connectionType: "plugin", requestId: "request-1", scope },
@@ -171,16 +166,8 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) {
participantIds: message.participantIds,
direction: message.direction,
sentAtMs: message.sentAtMs,
content: {
...message.content,
title: content.title,
products: content.products,
productCount: content.productCount,
status: content.status,
payment: content.payment,
delivery: content.delivery,
action: content.action,
},
content: message.content,
renderedCard: content,
},
requestId: "request-1",
scope: {
@@ -1,8 +1,8 @@
// 验证渲染卡片补全使用独立、带备注的追加迁移。
import assert from "node:assert/strict";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { ONETALK_INITIAL_HISTORY_GENERATION } from "@trade-message-center/onetalk-contract";
@@ -14,35 +14,32 @@ test("keeps the CJS-safe schema history default aligned with the shared contract
assert.equal(ONETALK_SCHEMA_INITIAL_HISTORY_GENERATION, ONETALK_INITIAL_HISTORY_GENERATION);
});
test("adds the rendered-card fact table in the latest additive migration", async () => {
const directory = resolve(process.cwd(), migrationDirectory);
const files = (await readdir(directory)).filter((file) => /^\d+_.+\.sql$/u.test(file)).sort();
const latest = files.at(-1);
assert.ok(latest, "expected at least one SQL migration");
const sql = await readFile(resolve(directory, latest), "utf8");
assert.match(sql, /CREATE TABLE "onetalk_rendered_card_content"/i);
assert.match(
sql,
/PRIMARY KEY\s*\("channel_account_id",\s*"conversation_id",\s*"message_id"\)/i,
test("drops obsolete audit columns and expands the rendered-card kinds without rewriting history", async () => {
const sourceMigrationUrl = new URL(`../${migrationDirectory}/`, import.meta.url);
const migrationUrl = existsSync(sourceMigrationUrl)
? sourceMigrationUrl
: new URL(`../../${migrationDirectory}/`, import.meta.url);
const migration015 = await readFile(new URL("0015_wild_toad_men.sql", migrationUrl), "utf8");
const migration016 = await readFile(
new URL("0016_optimal_imperial_guard.sql", migrationUrl),
"utf8",
);
assert.match(sql, /COMMENT ON TABLE "onetalk_rendered_card_content"/i);
for (const column of [
"channel_account_id",
"conversation_id",
"message_id",
"rendered_card_content",
"rendered_card_content_fingerprint",
"rendered_card_observed_at_ms",
"first_confirmed_at",
"last_observed_at",
"conflict_count",
"last_conflicting_fingerprint",
"last_conflict_observed_at_ms",
]) {
])
assert.match(
sql,
new RegExp(`COMMENT ON COLUMN "onetalk_rendered_card_content"."${column}"`, "i"),
migration015,
new RegExp(`ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "${column}"`, "i"),
);
}
assert.doesNotMatch(migration015, /\b(?:DELETE|TRUNCATE|CREATE\s+TABLE|CONSTRAINT)\b/i);
assert.doesNotMatch(migration016, /\b(?:DELETE|TRUNCATE|CREATE\s+TABLE|DROP\s+COLUMN)\b/i);
assert.match(migration016, /DROP CONSTRAINT "onetalk_rendered_card_content_v1_chk"/i);
assert.match(
migration016,
/ADD CONSTRAINT "onetalk_rendered_card_content_v1_chk" CHECK .*not .*\? 'kind'/is,
);
});
@@ -8,14 +8,9 @@ import { createOneTalkRenderedCardRepository } from "../src/onetalk/rendered-car
const content = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Hangzhou", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
status: "Paid",
total: "US $10.00",
image: "https://img.alicdn.com/card.jpg",
};
test("stores a rendered-card supplement when its base message is absent", async () => {
@@ -52,7 +47,6 @@ test("stores a rendered-card supplement when its base message is absent", async
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 100,
},
receivedAt: new Date("2026-09-15T00:00:00.000Z"),
});
assert.deepEqual(result, { status: "accepted", renderedCardContent: content, message: null });
@@ -64,8 +58,60 @@ test("stores a rendered-card supplement when its base message is absent", async
renderedCardContent: content,
renderedCardContentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
renderedCardObservedAtMs: 100,
firstConfirmedAt: new Date("2026-09-15T00:00:00.000Z"),
lastObservedAt: new Date("2026-09-15T00:00:00.000Z"),
},
]);
});
test("keeps the first supplement snapshot and only increments conflictCount for different content", async () => {
const updates: unknown[] = [];
const conflictingContent = { ...content, status: "Cancelled" };
const selectResults = [
[
{
channelAccountId: "account-1",
conversationId: "conversation-1",
messageId: "message-1",
renderedCardContent: content,
},
],
[],
];
const transaction = {
select: () => ({
from: () => ({
where: () => ({
limit: () => {
const result = selectResults.shift();
return Object.assign(Promise.resolve(result), { for: () => result });
},
}),
}),
}),
insert: () => assert.fail("unexpected insert"),
update: () => ({
set: (value: unknown) => ({
where: async () => updates.push(value),
}),
}),
};
const database = {
transaction: async (run: (value: typeof transaction) => Promise<unknown>) =>
run(transaction),
};
const repository = createOneTalkRenderedCardRepository(database as never);
const result = await repository.store({
channelAccountId: "account-1",
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content: conflictingContent,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(conflictingContent),
observedAtMs: 101,
},
});
assert.deepEqual(result, { status: "conflict", renderedCardContent: content, message: null });
assert.equal(updates.length, 1);
assert.deepEqual(Object.keys(updates[0] as object), ["conflictCount"]);
});
+5 -34
View File
@@ -837,14 +837,10 @@ test("executes a wire assertion for every declared OneTalk client frame", async
const renderedCardContent = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Matrix order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Matrix address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
status: "Paid",
total: "$1",
shippingAddress: "Matrix address",
productCount: { display: "0 items", value: 0 },
};
const renderedCardAck = nextMessage(plugin);
const renderedCardUpdate = nextFrame(mind, (frame) => frame.type === "message.updated");
@@ -880,32 +876,7 @@ test("executes a wire assertion for every declared OneTalk client frame", async
assert.deepEqual(renderedCardFrames[1]?.payload, {
message: {
...centerMessage(orderMessage("matrix-rendered-card-message")),
content: {
version: 1,
kind: "order",
orderId: "order-1",
bizCode: null,
contractId: null,
id: null,
tenant: null,
orderAmount: 1,
orderAmountCurrency: "USD",
paymentAmount: 1,
paymentAmountCurrency: "USD",
statusMessageKey: "paid",
actions: [],
title: "Matrix order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: {
shippingAddress: "Matrix address",
methodLabel: null,
dateLabel: null,
},
action: { label: null, status: null },
},
renderedCard: renderedCardContent,
},
});
assert.equal(renderedCardInputs.length, 1);
@@ -0,0 +1,186 @@
# OneTalk DOM Secondary Card Collection Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the Fiber-backed secondary card producer with strict DOM readers for inquiry, product, and order cards, while removing four obsolete server audit columns.
**Architecture:** A single DOM identity resolver validates card and wrapper metadata before three typed readers produce the existing rendered-card contract. The bridge and local ledger cease carrying unused Fiber-only base evidence; the server wire and JSON content shapes remain unchanged. The migration drops only approved columns and preserves first-content-wins with `conflict_count`.
**Tech Stack:** TypeScript, MV3 MAIN/ISOLATED page bridge, IndexedDB, shared OneTalk contract, Fastify/Drizzle/PostgreSQL, Node test runner.
**Spec:** `.trellis/tasks/09-16-onetalk-dom-secondary-card-collection/{prd,design,implement}.md`
## Global Constraints
- DOM is a structural data source; no React/Fiber inspection, root traversal, timer rescan, raw metadata forwarding, or fallback path.
- `messageId` must be present and match in card metadata and closest wrapper metadata whenever both exist; metadata conversation IDs must also agree when both exist.
- Quote (`cardType=8`) and unknown cards fail closed.
- Keep existing three rendered content JSON shapes, server frame, compound message identity, fingerprint, observedAtMs and `conflict_count`.
- Migration never issues DELETE, TRUNCATE, table rebuild or historical JSON rewrite.
---
### Task 1: DOM reader and legacy reader removal
**Files:**
- Create: `apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader.ts`
- Delete: `apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts`
- Modify: `apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts`
- Test: `apps/chrome-extension/test/onetalk-rendered-card-reader.test.js`
**Interfaces:**
- Produces: `readOneTalkRenderedCardFromDom(pageWindow, card): OneTalkRenderedCardObservation | null`.
- Consumes: `readConversationSelection`, `normalizeOneTalkProductUrl`, rendered-card validators and fingerprint creator.
- [ ] **Step 1: Replace Fiber fixtures with DOM fixtures and red tests.**
```js
assert.equal(readOneTalkRenderedCardFromDom(page, cardWith({ cardMessageId: "m-1", wrapperMessageId: "m-2" })), null);
assert.equal(readOneTalkRenderedCardFromDom(page, quoteCard({ cardType: 8 })), null);
assert.equal(readOneTalkRenderedCardFromDom(page, productCard({ rawUrl: "https://bad.example" })), null);
```
- [ ] **Step 2: Run the focused reader test and confirm the missing DOM reader fails.**
```text
node --experimental-strip-types --test apps/chrome-extension/test/onetalk-rendered-card-reader.test.js
```
- [ ] **Step 3: Implement the shared resolver and three projections.**
```ts
const messageId = cardMessageId ?? wrapperMessageId;
if (!messageId || (cardMessageId && wrapperMessageId && cardMessageId !== wrapperMessageId)) return null;
if (!sameOptionalConversation(cardInfo, wrapperInfo)) return null;
```
Construct only exact `rendered_inquiry`, `rendered_product`, or `rendered_order` shapes, validate with `isOneTalkRenderedCardContent`, and return an observation whose fingerprint is recomputed from that content.
- [ ] **Step 4: Rewrite entry to scan cards and react only to DOM mutations.**
```ts
for (const card of cardsForMutation(record)) publish(readOneTalkRenderedCardFromDom(pageWindow, card));
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-expinfo", "src", "style"] });
```
Remove `setInterval`, wrapper rescans, React imports, and the deleted reader file.
- [ ] **Step 5: Run focused reader/observer tests.**
```text
node --experimental-strip-types --test apps/chrome-extension/test/onetalk-rendered-card-reader.test.js apps/chrome-extension/test/onetalk-rendered-card-observer.test.js
```
### Task 2: Remove Fiber-only base evidence from local boundaries
**Files:**
- Modify: `apps/chrome-extension/src/onetalk/page-bridge/{main.ts,model.ts}`
- Modify: `apps/chrome-extension/src/onetalk/service-worker/{rendered-card-coordinator.ts,storage.ts}`
- Test: `apps/chrome-extension/test/{onetalk-page-bridge,onetalk-rendered-card-coordinator,onetalk-sync-storage}.test.js`
**Interfaces:**
- `onetalk.page.rendered-card-observed` contains `{ channelAccountId, observations }` only.
- `OneTalkRenderedCardCoordinator.observe(observations)` persists and flushes observations without base evidence.
- [ ] **Step 1: Add bridge and ledger tests that accept an observation-only message and reject legacy base-evidence fields.**
```js
assert.deepEqual(decodeOneTalkPageMessage({ source, version, type, channelAccountId, observations }), expected);
assert.equal("baseDirection" in pendingRecord, false);
```
- [ ] **Step 2: Run focused tests and confirm they fail against the old exact shape.**
```text
node --experimental-strip-types --test apps/chrome-extension/test/onetalk-page-bridge.test.js apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js apps/chrome-extension/test/onetalk-sync-storage.test.js
```
- [ ] **Step 3: Remove baseEvidence from the page message decoder/sink and from coordinator/ledger APIs.**
```ts
return coordinator.observe(message.observations);
await ledger.observe({ channelAccountId: scope.channelAccountId, observation });
```
Keep `markSent`, `listPending`, `markAcknowledged`, fingerprints and exact ACK matching unchanged.
- [ ] **Step 4: Re-run focused tests.**
```text
node --experimental-strip-types --test apps/chrome-extension/test/onetalk-page-bridge.test.js apps/chrome-extension/test/onetalk-rendered-card-coordinator.test.js apps/chrome-extension/test/onetalk-sync-storage.test.js
```
### Task 3: Remove approved server audit columns without data cleanup
**Files:**
- Modify: `apps/server/src/database/schema/onetalk.ts`
- Modify: `apps/server/src/onetalk/rendered-card-{model,repository,service}.ts`
- Create: generated `apps/server/drizzle/0015_*.sql` and matching `drizzle/meta` files
- Test: `apps/server/test/onetalk-rendered-card-{migration,repository,flow}.test.ts`
**Interfaces:**
- Stored row retains key, `renderedCardContent`, `renderedCardContentFingerprint`, `renderedCardObservedAtMs`, and `conflictCount`.
- Same content stays duplicate; different content increments `conflictCount` and returns conflict without overwriting JSON.
- [ ] **Step 1: Change migration/repository test expectations to the retained row shape and conflict-only audit.**
```ts
assert.equal(saved.conflictCount, 1);
assert.equal("lastObservedAt" in saved, false);
assert.doesNotMatch(migrationSql, /\b(?:DELETE|TRUNCATE)\b/i);
```
- [ ] **Step 2: Run the server rendered-card tests and confirm failure.**
```text
node --experimental-strip-types --test apps/server/test/onetalk-rendered-card-migration.test.ts apps/server/test/onetalk-rendered-card-repository.test.ts apps/server/test/onetalk-rendered-card-flow.test.ts
```
- [ ] **Step 3: Remove four Drizzle fields and generated SQL columns, then remove their repository/service writes.**
```sql
ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "first_confirmed_at";
ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_observed_at";
ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_conflicting_fingerprint";
ALTER TABLE "onetalk_rendered_card_content" DROP COLUMN "last_conflict_observed_at_ms";
```
Retain `conflict_count = conflict_count + 1` in the conflict branch and leave all supplement JSON untouched.
- [ ] **Step 4: Run migration/repository/flow tests and Drizzle validation.**
```text
node --experimental-strip-types --test apps/server/test/onetalk-rendered-card-migration.test.ts apps/server/test/onetalk-rendered-card-repository.test.ts apps/server/test/onetalk-rendered-card-flow.test.ts
pnpm --filter @trade-message-center/server db:check
```
### Task 4: Contract-preserving integration verification
**Files:**
- Test: existing contract, read-projection, extension and server suites
- [ ] **Step 1: Add read projection regressions for all three base kinds and reject mismatched supplement kinds.**
```ts
assert.equal(projected.content.kind, "inquiry");
assert.throws(() => toOneTalkCenterMessage(product, renderedInquiry));
```
- [ ] **Step 2: Run all scoped checks.**
```text
pnpm --filter @trade-message-center/onetalk-contract test
pnpm --filter @trade-message-center/chrome-extension test
pnpm --filter @trade-message-center/chrome-extension typecheck
pnpm --filter @trade-message-center/chrome-extension build
pnpm --filter @trade-message-center/server typecheck
git diff --check
```
- [ ] **Step 3: Run final source-boundary searches.**
```text
rg -n 'React|Fiber|baseEvidence|baseDirection|baseSentAtMs|setInterval' apps/chrome-extension/src/onetalk/main-page/card-observer apps/chrome-extension/src/onetalk/page-bridge apps/chrome-extension/src/onetalk/service-worker
```
Expected: no legacy secondary-card collector references; unrelated modules may remain outside the reviewed paths.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trade-message-center",
"version": "0.8.27",
"version": "0.8.28",
"private": true,
"workspaces": [
"apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/onetalk-contract",
"version": "0.8.27",
"version": "0.8.28",
"private": true,
"files": [
"dist"
+14 -2
View File
@@ -14,6 +14,7 @@ import {
isOneTalkRenderedCardPayment,
isOneTalkRenderedCardQuantity,
isOneTalkRenderedCardText,
isOneTalkRenderedCard,
type OneTalkRenderedCardAction,
type OneTalkRenderedCardDelivery,
type OneTalkRenderedCardImage,
@@ -21,6 +22,7 @@ import {
type OneTalkRenderedCardOrderStatus,
type OneTalkRenderedCardPayment,
type OneTalkRenderedCardQuantity,
type OneTalkRenderedCard,
} from "./rendered-cards.ts";
export const ONETALK_CONTENT_VERSION = 1 as const;
@@ -188,6 +190,8 @@ export type OneTalkCenterMessage = {
direction: OneTalkDirection;
sentAtMs: number;
content: OneTalkCenterMessageContent;
/** 与基础消息同一复合 identity 的 DOM-only 公共快照。 */
renderedCard?: OneTalkRenderedCard;
};
export type OneTalkMessageContentDecodeResult =
@@ -262,6 +266,8 @@ const CENTER_MESSAGE_KEYS = [
"content",
] as const;
const CENTER_MESSAGE_RENDERED_CARD_KEYS = [...CENTER_MESSAGE_KEYS, "renderedCard"] as const;
const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm";
const THUMBNAIL_URL_PATH = "/file/videoThumb.htm";
const ALLOWED_MEDIA_QUERY_KEYS = [
@@ -712,7 +718,12 @@ export const isCenterConversation = (value: unknown): value is CenterConversatio
/** 判断值是否为 exact-shape 的 Mind 公开消息读取模型。 */
export const isOneTalkCenterMessage = (value: unknown): value is OneTalkCenterMessage => {
if (!isPlainRecord(value) || !hasExactKeys(value, CENTER_MESSAGE_KEYS)) return false;
if (
!isPlainRecord(value) ||
(!hasExactKeys(value, CENTER_MESSAGE_KEYS) &&
!hasExactKeys(value, CENTER_MESSAGE_RENDERED_CARD_KEYS))
)
return false;
return (
isNonBlankString(value.messageId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.conversationId, MAX_IDENTIFIER_LENGTH) &&
@@ -721,6 +732,7 @@ export const isOneTalkCenterMessage = (value: unknown): value is OneTalkCenterMe
typeof value.direction === "string" &&
ONETALK_DIRECTIONS.includes(value.direction as OneTalkDirection) &&
isNonNegativeSafeInteger(value.sentAtMs) &&
isOneTalkCenterMessageContent(value.content)
isOneTalkCenterMessageContent(value.content) &&
(value.renderedCard === undefined || isOneTalkRenderedCard(value.renderedCard))
);
};
+3
View File
@@ -132,10 +132,12 @@ export {
ONETALK_RENDERED_CARD_CONTENT_VERSION,
ONETALK_RENDERED_CARD_ACK_STATUSES,
cloneOneTalkRenderedCardContent,
cloneOneTalkRenderedCard,
createOneTalkRenderedCardAckFrame,
createOneTalkRenderedCardContentFingerprint,
createOneTalkRenderedCardObservedFrame,
isOneTalkRenderedCardContent,
isOneTalkRenderedCard,
isOneTalkRenderedCardImage,
isOneTalkRenderedCardImageUrl,
isOneTalkRenderedCardIdentifier,
@@ -154,6 +156,7 @@ export type {
OneTalkRenderedCardAction,
OneTalkRenderedCardAckFrame,
OneTalkRenderedCardContent,
OneTalkRenderedCard,
OneTalkRenderedCardImage,
OneTalkRenderedCardOrderStatus,
OneTalkRenderedCardPayment,
@@ -54,15 +54,31 @@ export type OneTalkRenderedOrderContent = {
delivery: OneTalkRenderedCardDelivery;
action: OneTalkRenderedCardOrderAction;
};
/** 仅表达当前已渲染可见内容的类型无关命名卡片快照。 */
export type OneTalkRenderedCard = {
version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION;
title?: string;
image?: string;
price?: string;
minimumOrder?: string;
purchaseQuantity?: string;
requirementText?: string;
productCount?: { display: string; value: number | null };
status?: string;
total?: string;
deliveryDate?: string;
shippingAddress?: string;
};
export type OneTalkRenderedCardContent =
| OneTalkRenderedInquiryContent
| OneTalkRenderedProductContent
| OneTalkRenderedOrderContent;
| OneTalkRenderedOrderContent
| OneTalkRenderedCard;
export type OneTalkRenderedCardObservation = {
conversationId: string;
messageId: string;
content: OneTalkRenderedCardContent;
content: OneTalkRenderedCard;
contentFingerprint: string;
observedAtMs: number;
};
@@ -220,17 +236,77 @@ export const isSameOneTalkRenderedCardContent = (
right: OneTalkRenderedCardContent,
): boolean => canonicalize(left) === canonicalize(right);
/** 克隆跨世界传输的受限卡片快照,拒绝保留 Fiber 或 raw props 引用。 */
/** 克隆跨世界传输的受限卡片快照,拒绝保留框架对象或 raw props 引用。 */
export const cloneOneTalkRenderedCardContent = (
content: OneTalkRenderedCardContent,
): OneTalkRenderedCardContent => JSON.parse(canonicalize(content)) as OneTalkRenderedCardContent;
/** 克隆仅允许新 observation 写入的无 kind 命名快照。 */
export const cloneOneTalkRenderedCard = (content: OneTalkRenderedCard): OneTalkRenderedCard =>
JSON.parse(canonicalize(content)) as OneTalkRenderedCard;
const isBoundedNonEmptyArray = <T>(
value: unknown,
predicate: (item: unknown) => item is T,
): value is T[] =>
Array.isArray(value) && value.length > 0 && value.length <= MAX_ITEMS && value.every(predicate);
const isProductCount = (
value: unknown,
): value is NonNullable<OneTalkRenderedCard["productCount"]> =>
isPlainRecord(value) &&
hasExactKeys(value, ["display", "value"]) &&
isOneTalkRenderedCardText(value.display) &&
(value.value === null || isSafeNonNegativeInteger(value.value));
const RENDERED_CARD_KEYS = [
"version",
"title",
"image",
"price",
"minimumOrder",
"purchaseQuantity",
"requirementText",
"productCount",
"status",
"total",
"deliveryDate",
"shippingAddress",
] as const;
/** 验证无 kind、无 DOM 元数据的 type-agnostic 命名公开形状。 */
export const isOneTalkRenderedCard = (value: unknown): value is OneTalkRenderedCard => {
if (
!isPlainRecord(value) ||
value.version !== ONETALK_RENDERED_CARD_CONTENT_VERSION ||
Object.keys(value).some((key) => !RENDERED_CARD_KEYS.includes(key as never))
)
return false;
const namedFieldCount = Object.keys(value).length - 1;
return (
namedFieldCount > 0 &&
(value.title === undefined || isOneTalkRenderedCardText(value.title)) &&
(value.image === undefined || isOneTalkRenderedCardImageUrl(value.image)) &&
(value.price === undefined || isOneTalkRenderedCardText(value.price)) &&
(value.minimumOrder === undefined || isOneTalkRenderedCardText(value.minimumOrder)) &&
(value.purchaseQuantity === undefined ||
isOneTalkRenderedCardText(value.purchaseQuantity)) &&
(value.requirementText === undefined || isOneTalkRenderedCardText(value.requirementText)) &&
(value.productCount === undefined || isProductCount(value.productCount)) &&
(value.status === undefined || isOneTalkRenderedCardText(value.status)) &&
(value.total === undefined || isOneTalkRenderedCardText(value.total)) &&
(value.deliveryDate === undefined || isOneTalkRenderedCardText(value.deliveryDate)) &&
(value.shippingAddress === undefined || isOneTalkRenderedCardText(value.shippingAddress))
);
};
/** 解码精确 shape 的受控渲染卡片内容。 */
export const isOneTalkRenderedCardContent = (
value: unknown,
): value is OneTalkRenderedCardContent => {
if (!isPlainRecord(value) || value.version !== ONETALK_RENDERED_CARD_CONTENT_VERSION)
return false;
if (!("kind" in value)) return isOneTalkRenderedCard(value);
if (value.kind === "rendered_inquiry") {
return (
hasExactKeys(value, [
@@ -319,7 +395,7 @@ const isObservation = (value: unknown): value is OneTalkRenderedCardObservation
]) &&
isOneTalkRenderedCardIdentifier(value.conversationId) &&
isOneTalkRenderedCardIdentifier(value.messageId) &&
isOneTalkRenderedCardContent(value.content) &&
isOneTalkRenderedCard(value.content) &&
isOneTalkRenderedCardIdentifier(value.contentFingerprint) &&
isSafeNonNegativeInteger(value.observedAtMs) &&
value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content);
@@ -361,7 +437,7 @@ export const createOneTalkRenderedCardObservedFrame = (
type: "rendered.card.observed",
requestId: frame.requestId,
scope: frame.scope,
payload: { ...observation, content: cloneOneTalkRenderedCardContent(observation.content) },
payload: { ...observation, content: cloneOneTalkRenderedCard(observation.content) },
});
/** 创建对应精确 observation 的服务端 ACK。 */
+92 -26
View File
@@ -133,14 +133,9 @@ const textContent = {
test("strictly validates rendered-card frames, canonical fingerprints, and in-place updates", () => {
const content = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Order summary",
products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }],
productCount: 1,
status: { code: "paid", text: "Paid" },
payment: { totalDisplay: "US $10.00", discountDisplay: null },
delivery: { shippingAddress: "1 Market Street", methodLabel: null, dateLabel: null },
action: { label: "View order", status: "available" },
status: "Paid",
total: "US $10.00",
image: "https://img.alicdn.com/item.jpg",
};
assert.equal(isOneTalkRenderedCardContent(content), true);
const fingerprint = createOneTalkRenderedCardContentFingerprint(content);
@@ -154,8 +149,10 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl
assert.deepEqual(decodeOneTalkFrame(frame), { ok: true, frame });
assert.equal(
isSameOneTalkRenderedCardContent(content, {
...content,
status: { text: "Paid", code: "paid" },
image: "https://img.alicdn.com/item.jpg",
total: "US $10.00",
status: "Paid",
version: 1,
}),
true,
);
@@ -171,15 +168,7 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl
...frame,
payload: {
...frame.payload,
content: {
...content,
products: [
{
...content.products[0],
imageUrl: "https://img.alicdn.com/item.jpg?token=x",
},
],
},
content: { ...content, image: "https://img.alicdn.com/item.jpg?token=x" },
},
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
@@ -207,19 +196,96 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl
paymentAmountCurrency: "USD",
statusMessageKey: "paid",
actions: [],
title: content.title,
products: content.products,
productCount: content.productCount,
status: content.status,
payment: content.payment,
delivery: content.delivery,
action: content.action,
},
},
);
assert.deepEqual(decodeOneTalkFrame(updated), { ok: true, frame: updated });
});
test("rejects legacy typed rendered-card content on the observation wire", () => {
const legacyContent = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Order summary",
products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }],
productCount: 1,
status: { code: "paid", text: "Paid" },
payment: { totalDisplay: "US $10.00", discountDisplay: null },
delivery: { shippingAddress: "1 Market Street", methodLabel: null, dateLabel: null },
action: { label: "View order", status: "available" },
};
const frame = createOneTalkRenderedCardObservedFrame(frameBase, {
conversationId: "conversation-1",
messageId: "message-1",
content: legacyContent as never,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(legacyContent),
observedAtMs: 1_700_000_000_001,
});
assert.deepEqual(decodeOneTalkFrame(frame), {
ok: false,
code: ONETALK_ERROR_CODES.invalidMessage,
});
});
test("accepts only an exact named rendered card without a business kind on a CenterMessage", () => {
const renderedCard = {
version: 1 as const,
title: "Order #1",
total: "US $10.00",
image: "https://img.alicdn.com/card.jpg",
};
assert.equal(isOneTalkRenderedCardContent(renderedCard), true);
assert.equal(isOneTalkCenterMessage({ ...centerMessage, renderedCard }), true);
assert.equal(
isOneTalkCenterMessage({
...centerMessage,
renderedCard: { ...renderedCard, kind: "order" },
}),
false,
);
assert.equal(isOneTalkRenderedCardContent({ version: 1, texts: ["unstructured"] }), false);
});
test("accepts scalar rendered-card fields and rejects superseded collection shapes", () => {
assert.equal(
isOneTalkRenderedCardContent({
version: 1,
image: "https://img.alicdn.com/card.jpg",
purchaseQuantity: "500 Piece/Pieces",
minimumOrder: "10 Pieces",
}),
true,
);
for (const renderedCard of [
{
version: 1,
requirementText: "Need custom sizing",
reference: "13894257698",
},
{
version: 1,
images: ["https://img.alicdn.com/card.jpg"],
},
{
version: 1,
productImages: ["https://img.alicdn.com/card.jpg"],
},
{
version: 1,
promotionTexts: ["Super"],
},
{
version: 1,
attributes: [{ label: "采购数量", value: "500 Piece/Pieces" }],
},
{
version: 1,
minimumOrder: { value: "10", unit: "Pieces" },
},
])
assert.equal(isOneTalkRenderedCardContent(renderedCard), false);
});
const jpegContent = {
version: ONETALK_CONTENT_VERSION,
kind: "image" as const,