Merge pull request #52 from sinanyuntu/09-15-unify-onetalk-card-mind-types

统一 OneTalk 二次卡片 Mind 内容类型
This commit is contained in:
YBF
2026-09-15 19:02:23 +08:00
committed by GitHub
26 changed files with 1143 additions and 110 deletions
+1 -1
View File
@@ -36,7 +36,7 @@
| [错误处理](./error-handling.md) | HTTP/WS 稳定错误映射与安全边界 | 已建立 | | [错误处理](./error-handling.md) | HTTP/WS 稳定错误映射与安全边界 | 已建立 |
| [质量规范](./quality-guidelines.md) | 工具链与验证方式 | 已建立基线 | | [质量规范](./quality-guidelines.md) | 工具链与验证方式 | 已建立基线 |
| [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 | | [日志规范](./logging-guidelines.md) | 日志能力的当前边界 | 已建立基线 |
| [服务基础设施](./service-foundation.md) | Fastify、WebSocket 与 ORM 基础契约 | 已建立 | | [服务基础设施](./service-foundation.md) | Fastify、WebSocket、ORM 与 OneTalk Mind 公共投影契约 | 已建立 |
| [后台纪要内部网络读取](./summary-authorization.md) | 内部 7777 listener、Docker 网络边界、固定窗口与发布约束 | Center 独立契约 | | [后台纪要内部网络读取](./summary-authorization.md) | 内部 7777 listener、Docker 网络边界、固定窗口与发布约束 | Center 独立契约 |
| [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、workspace 声明、CORS/Origin 与 fail-closed 边界 | Center 已实现;真实 Mind/浏览器联调待验 | | [Mind HTTP 授权](./mind-authorization.md) | 两个 Mind 授权 HTTP 接口、同域 Cookie、workspace 声明、CORS/Origin 与 fail-closed 边界 | Center 已实现;真实 Mind/浏览器联调待验 |
| [OneTalk 单会话历史重建](./mind-history-rebuild.md) | rebuild 独立授权、scoped clear、generation/rebuild correlation、原子 reset 与 completion anchor | 已实现;真实 PostgreSQL/浏览器联调另行验证 | | [OneTalk 单会话历史重建](./mind-history-rebuild.md) | rebuild 独立授权、scoped clear、generation/rebuild correlation、原子 reset 与 completion anchor | 已实现;真实 PostgreSQL/浏览器联调另行验证 |
@@ -301,7 +301,7 @@ decodeOneTalkHistoryReadCursor(value) -> cursor | null
- 会话只读取 `conversation_kind = "direct"` 的显式 direct fact。列表/详情返回共享 `CenterConversation``name`/`avatarUrl` 为当前 profile row 的实时值,`participantIds` 固定为空数组,`unreadCount` 固定为 0`latestMessageId` 来自已确认业务锚点,`latestMessageAtMs` 来自 OneTalk 会话列表活动时间,两者允许独立为空。 - 会话只读取 `conversation_kind = "direct"` 的显式 direct fact。列表/详情返回共享 `CenterConversation``name`/`avatarUrl` 为当前 profile row 的实时值,`participantIds` 固定为空数组,`unreadCount` 固定为 0`latestMessageId` 来自已确认业务锚点,`latestMessageAtMs` 来自 OneTalk 会话列表活动时间,两者允许独立为空。
- 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 `onetalk_conversation.last_message_at_ms`,排序与 cursor 都使用绑定账号、query、asOf、`(latestMessageAtMs, conversationId)` 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。 - 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 `onetalk_conversation.last_message_at_ms`,排序与 cursor 都使用绑定账号、query、asOf、`(latestMessageAtMs, conversationId)` 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。
- 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 `(sentAtMs, messageId)` keyset;时间窗为 `from <= sentAtMs < to`。内部 `7777` summary listener 必须同时提供两端时间。 - 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 `(sentAtMs, messageId)` keyset;时间窗为 `from <= sentAtMs < to`。内部 `7777` summary listener 必须同时提供两端时间。
- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1``text | image | file | business_card | inquiry | order` union。`business_card` 在消息事实中严格是 `{ version: 1, kind: "business_card" }` markerread projection 先按同一 `channelAccountId + conversationId` 受限读取当前 profile,再以内存方式扩展为 `contactName``companyName``countryCode``avatarUrl` 四项 view 字段。没有 profile 时返回 markerprofile 部分缺失时对应字段为 `null`,且 view 不得写回 message/profile。read projection 不得解 Base64、`custom.data``contentType`、卡片原始正文/`params`、文件名或 URL fallback;它只复制 shared contract 已批准的字段。 - HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1``text | image | file | business_card | inquiry | order | product` union。`business_card` 在消息事实中严格是 `{ version: 1, kind: "business_card" }` markerread projection 先按同一 `channelAccountId + conversationId` 受限读取当前 profile,再以内存方式扩展为 `contactName``companyName``countryCode``avatarUrl` 四项 view 字段。没有 profile 时返回 markerprofile 部分缺失时对应字段为 `null`,且 view 不得写回 message/profile。read projection 不得解 Base64、`custom.data``contentType`、卡片原始正文/`params`、文件名或 URL fallback;它只复制 shared contract 已批准的字段。
- Mind 联调页由 `apps/mind-test-harness/` 提供,只通过同源 Bright HTTP/WS 访问数据;页面侧的运行时形状校验、文本转义、图片/文件展示和去重边界见 mind-test-harness 规范,不属于 server 路由契约。 - Mind 联调页由 `apps/mind-test-harness/` 提供,只通过同源 Bright HTTP/WS 访问数据;页面侧的运行时形状校验、文本转义、图片/文件展示和去重边界见 mind-test-harness 规范,不属于 server 路由契约。
- 插件 `plugin.status``sync.status``message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 `Content-Type`internal summary listener 不注册 CORS。 - 插件 `plugin.status``sync.status``message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 `Content-Type`internal summary listener 不注册 CORS。
@@ -369,6 +369,67 @@ const profile = await contactProfileRepository.read(scope.channelAccountId, conv
return projectCenterMessage({ ...message, content: stored }, profile ?? undefined); return projectCenterMessage({ ...message, content: stored }, profile ?? undefined);
``` ```
## Scenario: OneTalk 渲染卡补全的 Mind 公共投影
### 1. Scope / Trigger
- Trigger:插件已经独立持久化 `rendered.card.observed`,而 Bright 要经 HTTP history、`message.created``message.updated` 把对应展示信息提供给 Mind。
- Scope`packages/onetalk-contract/src/content.ts` 的 Center-only content union 与 `apps/server/src/onetalk/read-projection.ts` 的唯一投影;读取、live sync、send confirmation 和 rendered-card flow 都是该投影的消费者。
- Excluded:插件 wire、ACK、`onetalk_rendered_card_content` schema、补全去重/冲突规则、Trade-Mind 解码或展示;它们不得把内部 `rendered_*` type 当作 public content。
### 2. Signatures
```ts
toOneTalkCenterMessage(
message: OneTalkMessage,
renderedCardContent?: OneTalkRenderedCardContent,
customerProfile?: OneTalkReadCustomerProfile,
): OneTalkCenterMessage;
```
`OneTalkCenterMessageContent``inquiry``product``order` 分支分别可增加已批准的展示字段;`OneTalkMessageContent``OneTalkRenderedCardContent` 仍是彼此独立的持久化/输入契约。
### 3. Contracts
- 输出的判别字段始终是基础 `inquiry | product | order`,绝不输出 `rendered_inquiry | rendered_product | rendered_order`。无补全时,基础内容保持 exact shape,不用 `null` 或空对象伪造展示字段。
- `rendered_inquiry` 只能补充 `product``purchaseQuantity``requirementText``inquiryReference``actions``rendered_product` 只能补充 `title``imageUrl``priceDisplay``minimumOrder``serviceBadges``rendered_order` 只能补充 `title``products``productCount``status``payment``delivery``action`
- 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 顺序保持不变。
### 4. Validation & Error Matrix
| Condition | Result |
| --- | --- |
| 无 supplement | 公开基础 content exact shape,不新增可选字段 |
| supplement 与基础 kind 不一致 | 投影失败,不发布 HTTP/WS 伪内容 |
| rendered product 的 URL 或 product ID 不一致 | 投影失败,不以补全覆盖基础身份 |
| unknown key、错误嵌套或 ledger 字段进入 Center content | strict guard 拒绝 |
| supplement 先到、base message 后由 batch live accepted | repository 带回 supplement`message.created` 使用基础 kind 加批准字段 |
| duplicate/conflict supplement 或补全写失败 | 保持既有 ACK/冲突语义;不改写基础消息事实 |
### 5. Good / Base / Bad Cases
- GoodHTTP、`message.created``message.updated` 都调用同一个投影,订单公开为 `kind: "order"` 并可带 `status`/`payment`,内部 `rendered_order` 不离开服务端边界。
- 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_*`
- Repositorybatch accepted 以完整复合键读取已有 supplement,并保留 guard、transaction、ACK 与 publish 的既有顺序;测试不以 SQL JOIN 替代受限读取。
### 7. Wrong vs Correct
```ts
// Wrong: internal supplement replaces the public discriminated content.
toOneTalkCenterMessage({ ...message, content: renderedCardContent ?? message.content });
// Correct: the sole projection validates and merges two separate facts.
toOneTalkCenterMessage(message, renderedCardContent);
```
## Scenario: OneTalk WS 固定协议内核与静态已认证路由 ## Scenario: OneTalk WS 固定协议内核与静态已认证路由
### 1. Scope / Trigger ### 1. Scope / Trigger
@@ -0,0 +1,2 @@
{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "Server validation and review requirements"}
{"file": ".trellis/tasks/09-15-unify-onetalk-card-mind-types/prd.md", "reason": "Accepted scope and observable contract criteria"}
@@ -0,0 +1,51 @@
# 统一 OneTalk 二次卡片 Mind 类型设计
## 边界
插件继续产生 `rendered.card.observed`,服务端继续把已验证的 `OneTalkRenderedCardContent` 写入
`onetalk_rendered_card_content`。这些均为 Bright 内部协议和存储格式。本设计只改变 Center 公共消息
投影:HTTP `messages[]``message.created``message.updated` 均输出相同的基础 kind 内容。
## 公共内容契约
`OneTalkMessageContent` 保持插件输入的 exact-shape 校验,不接收二次字段。新增仅供 Center 输出的三种
内容类型,并组成 `OneTalkCenterMessageContent`
| 基础 kind | 原有字段 | 仅在有补全时增加的字段 |
| --- | --- | --- |
| `inquiry` | `version`, `kind` | `product?`, `purchaseQuantity?`, `requirementText?`, `inquiryReference?`, `actions?` |
| `product` | `version`, `kind`, `sourceUrl`, `productId` | `title?`, `imageUrl?`, `priceDisplay?`, `minimumOrder?`, `serviceBadges?` |
| `order` | 当前全部订单原始字段 | `title?`, `products?`, `productCount?`, `status?`, `payment?`, `delivery?`, `action?` |
缺少补全时,三个内容仍为原有 exact shape;存在补全时只允许表中列出的可选键。嵌套图像、数量、动作、
订单状态、金额与配送对象沿用 rendered-card 的既有字段校验;内部判断函数被导出复用,避免产生第二套
字段验证规则。
## 投影与一致性
`toOneTalkCenterMessage` 接收基础消息及可选补全内容,成为唯一 public projection owner
1. 无补全时直接投影基础内容。
2. 补全 kind 必须与基础 kind 对应;否则抛出明确错误,绝不猜测类型或静默降级。
3. `rendered_product` 只能补充标题、图片、价格、MOQ、服务标记;输出中的 `sourceUrl``productId`
必须保留基础消息值,并要求与补全值一致。
4. 所有输出通过 `isOneTalkCenterMessageContent` 严格验证后才能进入 HTTP 或 WebSocket frame。
读取模型已把补全内容带回基础消息行;`projectCenterMessage` 将其作为 optional supplement 传给该函数。
实时路径亦传入基础消息加 supplement:历史/实时 observation、发送确认与 rendered-card 补全通知都不得
自行替换 `content`
rendered-card repository 在补全到达且基础消息已存在时,返回基础消息内容和已保存 supplement 的两个事实,
供唯一投影函数合并;不更改写入、去重、冲突记录、ACK 或发布时序。
## 兼容与回滚
不改数据库 schema 或迁移;已有 rendered-card JSONB 继续可读取,并在下一次 HTTP 读取或实时发布时被
转换为基础 kind。回滚只需恢复公共投影代码,内部记录、插件 wire 和原始消息事实不受影响。
## 验证策略
- contract 测试覆盖三种补全投影的完整和缺省形状,以及未知键、错误嵌套、kind/商品身份不一致。
- server read-domain 测试覆盖 HTTP 读取投影;WebSocket 测试覆盖 `message.created`
`message.updated`
- 保留现有插件 card reader、ledger / ACK 和服务端 repository 测试,证明内部协议没有回归。
@@ -0,0 +1,2 @@
{"file": ".trellis/spec/project/module-ownership.md", "reason": "Shared contract type ownership and canonical exports"}
{"file": ".trellis/tasks/09-15-unify-onetalk-card-mind-types/design.md", "reason": "Public content contract, projection ownership, and internal-boundary decisions"}
@@ -0,0 +1,67 @@
# 统一 OneTalk 二次卡片 Mind 类型实施计划
**目标:** 只改变 Bright 面向 Mind 的 HTTP 与 WebSocket 消息内容,使二次卡片补全成为
`product``inquiry``order` 的可选字段。
**全局约束:** 不改插件、Page bridge、rendered-card wire、ACK、数据库 schema 或 Trade-Mind;保留
基础消息的字段和值;HTTP 与 WebSocket 必须复用同一公开投影。
## 文件与职责
- 修改 `packages/onetalk-contract/src/content.ts`:定义并验证 Center-only 卡片补全内容。
- 修改 `packages/onetalk-contract/src/rendered-cards.ts`:导出 Center 校验可复用的嵌套值 guard,不改变
rendered-card frame shape。
- 修改 `apps/server/src/onetalk/read-projection.ts`:唯一地合并基础内容与 rendered supplement。
- 修改 `apps/server/src/onetalk/rendered-card-model.ts``rendered-card-repository.ts`:补全实时更新返回
基础消息与 supplement,而不是已经替换 kind 的公开消息。
- 修改 `apps/server/src/websocket/plugin/flows/rendered-card-flow.ts``sync-flows.ts`
`send-confirmation-flow.ts`:把基础消息和 supplement 交给统一投影。
- 修改 `packages/onetalk-contract/test/contract.test.ts``apps/server/test/onetalk-read-domain.test.ts`
`apps/server/test/onetalk-rendered-card-flow.test.ts``apps/server/test/onetalk-websocket.test.ts`:锁定
HTTP / WebSocket 公开契约和内部帧不变性。
## 执行步骤
### 1. 先锁定公共 contract
- [ ]`contract.test.ts` 写三组 Center `content` 断言:基础 `inquiry` / `product` / `order` 不带
supplement 时保持 exact shape;带 supplement 时 kind 不变且仅出现已确认可选键;`rendered_*` 不再是
Center content。
- [ ] 写失败用例:未知键、部分嵌套对象、补全 kind 与基础 kind 不符、商品补全标识不同均被拒绝。
- [ ]`content.ts` 定义 `OneTalkCenterInquiryContent``OneTalkCenterProductContent`
`OneTalkCenterOrderContent`,仅让 `OneTalkCenterMessageContent` 使用它们;`decodeOneTalkMessageContent`
继续只接受插件原始 exact shape。
- [ ]`rendered-cards.ts` 导出图像、数量、动作和订单显示字段的值 guard,并由 Center guard 调用;
不改变 `OneTalkRenderedCardContent``rendered.card.observed`
- [ ] 运行 `pnpm --filter @trade-message-center/onetalk-contract test`
### 2. 将补全合并收敛为一个服务端投影
- [ ]`onetalk-read-domain.test.ts` 先写三个基础消息加 supplement 的断言,验证输出使用基础 kind、
基础 product 标识不被覆盖、无 supplement 的内容完全不变。
- [ ]`read-projection.ts` 增加将 `OneTalkMessageContent` 和可选 `OneTalkRenderedCardContent`
合并为 `OneTalkCenterMessageContent` 的私有函数,并让 `toOneTalkCenterMessage`
`projectCenterMessage` 都只经过该函数。
- [ ]`rendered-card-model.ts` / `rendered-card-repository.ts` 保留现有写入、冲突与 ACK 结果,但让
`message` 保持基础内容;将已保存的 rendered content 作为独立的 supplement 返回。
- [ ] 运行 `pnpm --filter @trade-message-center/server test -- onetalk-read-domain.test.ts`
或等价 Node 定向测试,确认 HTTP 投影覆盖三种类型。
### 3. 接入所有 Mind WebSocket 发布路径
- [ ]`onetalk-rendered-card-flow.test.ts` 把补全到达后的 `message.updated` 期望改为基础 kind 加
可选字段,并确认 ACK payload 未改变。
- [ ]`onetalk-websocket.test.ts` 覆盖:补全先到后基础 live message 产生 `message.created`
基础先到后补全产生 `message.updated`、发送确认命中已有补全;三种情况下均不得发布 `rendered_*`
- [ ]`rendered-card-flow.ts``sync-flows.ts``send-confirmation-flow.ts` 删除局部
`renderedCardContent ?? message.content` 的替换逻辑,改为将基础消息和 optional supplement 传入
`toOneTalkCenterMessage`
- [ ] 运行 `node --experimental-strip-types --test apps/server/test/onetalk-rendered-card-flow.test.ts
apps/server/test/onetalk-websocket.test.ts`。
### 4. 全量边界检查
- [ ] 运行 `pnpm --filter @trade-message-center/onetalk-contract typecheck`、
`pnpm --filter @trade-message-center/server typecheck`、目标 contract/server 测试。
- [ ] 运行 `git diff --check`,人工确认不存在 Chrome 插件与 Trade-Mind 路径改动,且无数据库迁移。
- [ ] 在提交前执行 GitNexus `detect_changes`,审阅受影响执行流只限 contract、读取投影与 Mind 发布。
@@ -0,0 +1,65 @@
# 统一 OneTalk 二次卡片 Mind 类型
## Goal
让 Mind 通过 Bright HTTP 历史读取和实时 WebSocket 通知接收的二次卡片保持既有
`product``inquiry``order` 判别类型;二次采集出的展示信息作为各类型的可选字段,
不向 Mind 暴露 `rendered_product``rendered_inquiry``rendered_order`
## Confirmed Facts
- 插件与 Bright 内部仍使用 `rendered.card.observed` 帧和 `OneTalkRenderedCardContent` 补全账本;
本任务不改采集、桥接、持久化格式或 ACK 语义。
- Bright HTTP 历史路由和 `message.created` / `message.updated` 都经同一公开消息投影;当前投影以
`renderedCardContent` 直接替换基础 `content`,因此泄漏了 `rendered_*` kind。
- 基础内容已定义 `inquiry``order``product`;二次采集已拥有三类对应展示字段。
## Requirements
- R1:对已关联二次补全的基础消息,Bright 对 Mind 的 HTTP 历史响应与实时消息通知都保持基础
`content.kind`,不得输出任何 `rendered_*` kind。
- R2`inquiry``order``product` 的原有字段和含义不变;没有二次补全时,不新增展示字段。
- R3:二次补全信息只作为对应基础类型的可选字段公开;不得公开 raw React Fiber props、内部
`contentFingerprint``observedAtMs` 或补全账本的内部类型。
- R4:不修改 Chrome 插件采集、Page bridge、`rendered.card.observed` / ACK 协议或补全表 schema。
- R5:本仓公开类型验证继续严格拒绝未知字段、错误嵌套形状、补全类型与基础类型不一致,以及商品补全
身份与基础 `sourceUrl` / `productId` 不一致。
### Confirmed Public Field Map
- `inquiry`:保留 `{ version, kind }`,并可选增加
`product``purchaseQuantity``requirementText``inquiryReference``actions`
- `product`:保留必填 `sourceUrl``productId`,并可选增加
`title``imageUrl``priceDisplay``minimumOrder``serviceBadges`
- `order`:保留现有原始订单字段,并可选增加
`title``products``productCount``status``payment``delivery``action`
- 补全字段只在存在已接受补全时出现;内部 `rendered_product.product.sourceUrl``productId`
不覆盖基础 `product` 的同名事实。
## Acceptance Criteria
- [ ] HTTP 历史响应中,已补全的订单、询盘、商品卡 `content.kind` 分别仍为
`order``inquiry``product`
- [ ] WebSocket `message.created` / `message.updated` 中的相同消息使用与 HTTP 一致的内容形状。
- [ ] 不含二次补全的既有基础卡片保持既有形状;二次字段按可选字段缺省,不以 `null` 或空对象伪造。
- [ ] 插件侧现有二次采集、独立提交、重连补发与 ACK 测试保持通过。
- [ ] Bright contract/server 的定向测试、类型检查通过。
## Out of Scope
- 修改插件采集字段、DOM/Fiber 分类、内部 rendered-card wire 帧或数据库补全表。
- 修改 Trade-Mind 的类型、HTTP / WebSocket 解码、状态管理或消息展示;Mind 端自行兼容本次公开契约。
- 为卡片新增操作能力、详情跳转、购买/询盘动作处理或 UI 视觉设计。
- 修改历史消息的基础身份、分页、授权或发送流程。
## Planning Decision
只修改 `trade-message-center`:先在公共 Center content union 中表达保持基础 kind 的补全投影,
再让 HTTP、`message.created``message.updated` 复用同一个投影函数。内部 rendered-card 内容继续
仅作为持久化补全事实,不能作为 Mind-facing content 直接发布。
## Notes
- Keep `prd.md` focused on requirements, constraints, and acceptance criteria.
- Lightweight tasks can remain PRD-only.
- For complex tasks, add `design.md` for technical design and `implement.md` for execution planning before `task.py start`.
@@ -0,0 +1,26 @@
{
"id": "unify-onetalk-card-mind-types",
"name": "unify-onetalk-card-mind-types",
"title": "统一 OneTalk 二次卡片 Mind 类型",
"description": "将二次采集卡片投影为基本类型的可选字段",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-15",
"completedAt": null,
"branch": "09-15-unify-onetalk-card-mind-types",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+3 -2
View File
@@ -8,7 +8,7 @@
<!-- @@@auto:current-status --> <!-- @@@auto:current-status -->
- **Active File**: `journal-1.md` - **Active File**: `journal-1.md`
- **Total Sessions**: 74 - **Total Sessions**: 75
- **Last Active**: 2026-09-15 - **Last Active**: 2026-09-15
<!-- @@@/auto:current-status --> <!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents --> <!-- @@@auto:active-documents -->
| File | Lines | Status | | File | Lines | Status |
|------|-------|--------| |------|-------|--------|
| `journal-1.md` | ~1613 | Active | | `journal-1.md` | ~1635 | Active |
<!-- @@@/auto:active-documents --> <!-- @@@/auto:active-documents -->
--- ---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history --> <!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch | | # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------| |---|------|-------|---------|--------|
| 75 | 2026-09-15 | 统一 OneTalk 二次卡片 Mind 类型 | `905e9fb` | `09-15-unify-onetalk-card-mind-types` |
| 74 | 2026-09-15 | 统一 OneTalk buyer facts IndexedDB | `cd888ac` | `dev-fix` | | 74 | 2026-09-15 | 统一 OneTalk buyer facts IndexedDB | `cd888ac` | `dev-fix` |
| 73 | 2026-09-15 | 完成 React 卡片监测 | `b28ccb0` | `09-14-react-card-monitor` | | 73 | 2026-09-15 | 完成 React 卡片监测 | `b28ccb0` | `09-14-react-card-monitor` |
| 72 | 2026-09-15 | Index OneTalk durable ledger queries | `c4623ee` | `09-14-onetalk-indexeddb-query-indexes` | | 72 | 2026-09-15 | Index OneTalk durable ledger queries | `c4623ee` | `09-14-onetalk-indexeddb-query-indexes` |
+22
View File
@@ -1611,3 +1611,25 @@ Enforced unique workspace context across Mind authorization HTTP and WebSocket f
### Next Steps ### Next Steps
- 修复既有 readAll 类型错误后重建并加载扩展,完成 v10 浏览器写入 smoke - 修复既有 readAll 类型错误后重建并加载扩展,完成 v10 浏览器写入 smoke
## Session 75: 统一 OneTalk 二次卡片 Mind 类型
<!-- trellis-session: v=2 fp=657fa9aae6e6751f -->
**Date**: 2026-09-15
**Task**: 统一 OneTalk 二次卡片 Mind 类型
**Branch**: `09-15-unify-onetalk-card-mind-types`
### Summary
完成 OneTalk 二次卡片的 Mind 公开投影统一:保留基础 kind、增加严格可选补全字段、复用 HTTP 与 WebSocket 投影并补齐回归测试。contract/server/extension 完整测试、类型检查和构建通过;全仓格式检查仍受未改动的 observation-pipeline Oxfmt 基线问题影响。
### Git Commits
| Hash | Message |
|------|---------|
| `905e9fb` | fix: unify OneTalk rendered card Mind content types |
### Status
[OK] **Completed**
+75 -11
View File
@@ -2,10 +2,13 @@
import { import {
isOneTalkCenterMessageContent, isOneTalkCenterMessageContent,
isOneTalkMessageContent,
isOneTalkRenderedCardContent,
type OneTalkHttpConversation, type OneTalkHttpConversation,
type OneTalkCenterMessage, type OneTalkCenterMessage,
type OneTalkMessage, type OneTalkMessage,
type OneTalkCenterMessageContent, type OneTalkCenterMessageContent,
type OneTalkRenderedCardContent,
} from "@trade-message-center/onetalk-contract"; } from "@trade-message-center/onetalk-contract";
import type { import type {
@@ -85,10 +88,73 @@ export const toHttpConversation = (
type OneTalkCenterMessageInput = Pick< type OneTalkCenterMessageInput = Pick<
OneTalkMessage, OneTalkMessage,
"messageId" | "conversationId" | "senderId" | "participantIds" | "direction" | "sentAtMs" | "messageId"
| "conversationId"
| "senderId"
| "participantIds"
| "direction"
| "sentAtMs"
| "content"
>; >;
type OneTalkCenterMessageInputWithEffectiveContent = Omit<OneTalkCenterMessageInput, "content"> & {
content: OneTalkCenterMessageContent; const supplementMessageContent = (
content: OneTalkMessage["content"],
renderedCardContent: OneTalkRenderedCardContent | undefined,
): OneTalkCenterMessageContent => {
if (!isOneTalkMessageContent(content)) {
throw new Error("Invalid persisted OneTalk message content");
}
if (renderedCardContent === undefined) return content;
if (!isOneTalkRenderedCardContent(renderedCardContent)) {
throw new Error("Invalid persisted OneTalk rendered-card supplement");
}
if (renderedCardContent.kind === "rendered_inquiry") {
if (content.kind !== "inquiry") {
throw new Error("OneTalk rendered-card supplement kind does not match base message");
}
return {
...content,
product: renderedCardContent.product,
purchaseQuantity: renderedCardContent.purchaseQuantity,
requirementText: renderedCardContent.requirementText,
inquiryReference: renderedCardContent.inquiryReference,
actions: renderedCardContent.actions,
};
}
if (renderedCardContent.kind === "rendered_product") {
if (content.kind !== "product") {
throw new Error("OneTalk rendered-card supplement kind does not match base message");
}
if (
content.sourceUrl !== renderedCardContent.product.sourceUrl ||
content.productId !== renderedCardContent.product.productId
) {
throw new Error(
"OneTalk rendered-product supplement identity does not match base message",
);
}
return {
...content,
title: renderedCardContent.product.title,
imageUrl: renderedCardContent.product.imageUrl,
priceDisplay: renderedCardContent.priceDisplay,
minimumOrder: renderedCardContent.minimumOrder,
serviceBadges: renderedCardContent.serviceBadges,
};
}
if (content.kind !== "order") {
throw new Error("OneTalk rendered-card supplement kind does not match base message");
}
return {
...content,
title: renderedCardContent.title,
products: renderedCardContent.products,
productCount: renderedCardContent.productCount,
status: renderedCardContent.status,
payment: renderedCardContent.payment,
delivery: renderedCardContent.delivery,
action: renderedCardContent.action,
};
}; };
const projectMessageContent = ( const projectMessageContent = (
@@ -110,13 +176,15 @@ const projectMessageContent = (
/** 从 normalized JSONB 事实和会话客户资料投影 Mind-facing 消息。 */ /** 从 normalized JSONB 事实和会话客户资料投影 Mind-facing 消息。 */
export const toOneTalkCenterMessage = ( export const toOneTalkCenterMessage = (
message: OneTalkCenterMessageInputWithEffectiveContent, message: OneTalkCenterMessageInput,
renderedCardContent?: OneTalkRenderedCardContent,
customerProfile?: Pick< customerProfile?: Pick<
OneTalkReadCustomerProfile, OneTalkReadCustomerProfile,
"name" | "avatarUrl" | "countryCode" | "companyName" "name" | "avatarUrl" | "countryCode" | "companyName"
>, >,
): OneTalkCenterMessage => { ): OneTalkCenterMessage => {
if (!isOneTalkCenterMessageContent(message.content)) { const content = supplementMessageContent(message.content, renderedCardContent);
if (!isOneTalkCenterMessageContent(content)) {
throw new Error("Invalid persisted OneTalk message content"); throw new Error("Invalid persisted OneTalk message content");
} }
return { return {
@@ -126,7 +194,7 @@ export const toOneTalkCenterMessage = (
participantIds: [...message.participantIds], participantIds: [...message.participantIds],
direction: message.direction, direction: message.direction,
sentAtMs: message.sentAtMs, sentAtMs: message.sentAtMs,
content: projectMessageContent(message.content, customerProfile), content: projectMessageContent(content, customerProfile),
}; };
}; };
@@ -136,8 +204,4 @@ export const projectCenterMessage = (
OneTalkReadCustomerProfile, OneTalkReadCustomerProfile,
"name" | "avatarUrl" | "countryCode" | "companyName" "name" | "avatarUrl" | "countryCode" | "companyName"
>, >,
): OneTalkCenterMessage => ): OneTalkCenterMessage => toOneTalkCenterMessage(row, row.renderedCardContent, customerProfile);
toOneTalkCenterMessage(
{ ...row, content: row.renderedCardContent ?? row.content },
customerProfile,
);
@@ -1,7 +1,7 @@
// 定义 OneTalk 渲染卡片补全的服务端领域边界 // 定义 OneTalk 渲染卡片补全的服务端领域边界
import type { import type {
OneTalkCenterMessage, OneTalkMessage,
OneTalkRenderedCardContent, OneTalkRenderedCardContent,
OneTalkRenderedCardObservation, OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract"; } from "@trade-message-center/onetalk-contract";
@@ -10,18 +10,18 @@ import type { OneTalkCommitGuard } from "./model.ts";
export type OneTalkRenderedCardStoreResult = export type OneTalkRenderedCardStoreResult =
| { | {
status: "accepted"; status: "accepted";
content: OneTalkRenderedCardContent; renderedCardContent: OneTalkRenderedCardContent;
message: OneTalkCenterMessage | null; message: OneTalkMessage | null;
} }
| { | {
status: "duplicate"; status: "duplicate";
content: OneTalkRenderedCardContent; renderedCardContent: OneTalkRenderedCardContent;
message: OneTalkCenterMessage | null; message: OneTalkMessage | null;
} }
| { | {
status: "conflict"; status: "conflict";
content: OneTalkRenderedCardContent; renderedCardContent: OneTalkRenderedCardContent;
message: OneTalkCenterMessage | null; message: OneTalkMessage | null;
}; };
export type OneTalkRenderedCardRepository = { export type OneTalkRenderedCardRepository = {
@@ -3,7 +3,7 @@
import { and, eq, sql } from "drizzle-orm"; import { and, eq, sql } from "drizzle-orm";
import { import {
isSameOneTalkRenderedCardContent, isSameOneTalkRenderedCardContent,
type OneTalkCenterMessage, type OneTalkMessage,
type OneTalkRenderedCardContent, type OneTalkRenderedCardContent,
} from "@trade-message-center/onetalk-contract"; } from "@trade-message-center/onetalk-contract";
import type { Database } from "../database/index.ts"; import type { Database } from "../database/index.ts";
@@ -32,17 +32,17 @@ const baseMessageCondition = (input: {
eq(onetalkMessage.conversationId, input.observation.conversationId), eq(onetalkMessage.conversationId, input.observation.conversationId),
eq(onetalkMessage.messageId, input.observation.messageId), eq(onetalkMessage.messageId, input.observation.messageId),
); );
const effectiveMessageFor = ( const baseMessageFor = (row: typeof onetalkMessage.$inferSelect): OneTalkMessage => ({
row: typeof onetalkMessage.$inferSelect,
content: OneTalkRenderedCardContent,
): OneTalkCenterMessage => ({
messageId: row.messageId, messageId: row.messageId,
conversationId: row.conversationId, conversationId: row.conversationId,
senderId: row.senderId, senderId: row.senderId,
participantIds: [...row.participantIds], participantIds: [...row.participantIds],
direction: row.direction, direction: row.direction,
sentAtMs: row.sentAtMs, sentAtMs: row.sentAtMs,
content, content: row.content,
readStatus: row.readStatus,
messageStatus: row.messageStatus,
unreadCount: row.unreadCount,
}); });
const existingBaseMessageFor = async ( const existingBaseMessageFor = async (
transaction: Parameters<Parameters<Database["transaction"]>[0]>[0], transaction: Parameters<Parameters<Database["transaction"]>[0]>[0],
@@ -87,10 +87,8 @@ const store = async (
input.commitGuard?.assertValid(); input.commitGuard?.assertValid();
return { return {
status: "accepted", status: "accepted",
content: input.observation.content, renderedCardContent: input.observation.content,
message: baseMessage message: baseMessage ? baseMessageFor(baseMessage) : null,
? effectiveMessageFor(baseMessage, input.observation.content)
: null,
}; };
} }
const saved = record.renderedCardContent as OneTalkRenderedCardContent; const saved = record.renderedCardContent as OneTalkRenderedCardContent;
@@ -104,8 +102,8 @@ const store = async (
input.commitGuard?.assertValid(); input.commitGuard?.assertValid();
return { return {
status: "duplicate", status: "duplicate",
content: saved, renderedCardContent: saved,
message: baseMessage ? effectiveMessageFor(baseMessage, saved) : null, message: baseMessage ? baseMessageFor(baseMessage) : null,
}; };
} }
await transaction await transaction
@@ -121,8 +119,8 @@ const store = async (
input.commitGuard?.assertValid(); input.commitGuard?.assertValid();
return { return {
status: "conflict", status: "conflict",
content: saved, renderedCardContent: saved,
message: baseMessage ? effectiveMessageFor(baseMessage, saved) : null, message: baseMessage ? baseMessageFor(baseMessage) : null,
}; };
}); });
+11 -1
View File
@@ -606,7 +606,17 @@ const guardedObserveMessages = async (
) )
: aggregate?.lastContactTimeMs, : aggregate?.lastContactTimeMs,
}); });
results.push({ status: "accepted", message: toMessage(insertedRow) }); const renderedCardContent = await renderedCardContentFor(
transaction,
context,
message,
);
guard.assertValid();
results.push({
status: "accepted",
message: toMessage(insertedRow),
...(renderedCardContent === null ? {} : { renderedCardContent }),
});
continue; continue;
} }
@@ -6,6 +6,7 @@ import {
type OneTalkErrorCode, type OneTalkErrorCode,
type OneTalkRenderedCardObservedFrame, type OneTalkRenderedCardObservedFrame,
} from "@trade-message-center/onetalk-contract"; } from "@trade-message-center/onetalk-contract";
import { toOneTalkCenterMessage } from "../../../onetalk/read-projection.ts";
import type { OneTalkRenderedCardService, OneTalkSourceContext } from "../../../onetalk/index.ts"; import type { OneTalkRenderedCardService, OneTalkSourceContext } from "../../../onetalk/index.ts";
import type { OneTalkAuthenticatedClientFrame } from "../../authenticated-router.ts"; import type { OneTalkAuthenticatedClientFrame } from "../../authenticated-router.ts";
import type { OneTalkConnectionRegistry, OneTalkRegisteredConnection } from "../../registry.ts"; import type { OneTalkConnectionRegistry, OneTalkRegisteredConnection } from "../../registry.ts";
@@ -95,7 +96,7 @@ export const createOneTalkRenderedCardFlow = (options: {
if (result.status !== "accepted" || result.message === null) return; if (result.status !== "accepted" || result.message === null) return;
guard.assertValid(); guard.assertValid();
await options.registry.publishMessageUpdated({ await options.registry.publishMessageUpdated({
message: result.message, message: toOneTalkCenterMessage(result.message, result.renderedCardContent),
requestId: frame.requestId, requestId: frame.requestId,
scope: scopeFor(context), scope: scopeFor(context),
policyEpoch, policyEpoch,
@@ -53,10 +53,7 @@ export const createOneTalkSendConfirmationFlow = (
if (result.status === "accepted") { if (result.status === "accepted") {
pending.commitGuard.assertValid(); pending.commitGuard.assertValid();
await options.registry.publishMessageCreated({ await options.registry.publishMessageCreated({
message: toOneTalkCenterMessage({ message: toOneTalkCenterMessage(result.message, result.renderedCardContent),
...result.message,
content: result.renderedCardContent ?? result.message.content,
}),
requestId: pending.frame.requestId, requestId: pending.frame.requestId,
scope: pending.mind.mindScope, scope: pending.mind.mindScope,
policyEpoch: pending.policyEpoch, policyEpoch: pending.policyEpoch,
@@ -393,10 +393,10 @@ export const createOneTalkSyncFlows = (options: OneTalkSyncFlowsOptions): OneTal
queued.guard.assertValid(); queued.guard.assertValid();
const scope = scopeForContext(request.context); const scope = scopeForContext(request.context);
await options.publishMessageCreated({ await options.publishMessageCreated({
message: toOneTalkCenterMessage({ message: toOneTalkCenterMessage(
...result.message, result.message,
content: result.renderedCardContent ?? result.message.content, result.renderedCardContent,
}), ),
requestId: queued.frame.requestId, requestId: queued.frame.requestId,
scope, scope,
policyEpoch: queued.policyEpoch, policyEpoch: queued.policyEpoch,
+112
View File
@@ -9,11 +9,17 @@ import type {
} from "@trade-message-center/onetalk-contract"; } from "@trade-message-center/onetalk-contract";
import { import {
createOneTalkRepository,
createOneTalkService, createOneTalkService,
type OneTalkConversationState, type OneTalkConversationState,
type OneTalkRepository, type OneTalkRepository,
type OneTalkSourceContext, type OneTalkSourceContext,
} from "../src/onetalk/index.ts"; } from "../src/onetalk/index.ts";
import {
onetalkConversation,
onetalkMessage,
onetalkRenderedCardContent,
} from "../src/database/schema/onetalk.ts";
const context: OneTalkSourceContext = { const context: OneTalkSourceContext = {
binding: "binding-1", binding: "binding-1",
@@ -412,6 +418,112 @@ test("persists an approved structured order without server-side card parsing", a
assert.equal(JSON.stringify(result).includes("sign"), false); assert.equal(JSON.stringify(result).includes("sign"), false);
}); });
test("returns an existing rendered-card supplement for a batched live observation", async () => {
const observedMessage = 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: [],
},
});
const renderedCardContent = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Late 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 },
};
const baseRow = {
channelAccountId: context.channelAccountId,
conversationId: "conversation-1",
messageId: "message-1",
senderId: "sender-1",
binding: context.binding,
mindUserId: context.mindUserId,
workspaceId: context.workspaceId,
deviceId: context.deviceId,
direction: "received" as const,
observationType: "new" as const,
sentAtMs: 1_700_000_000_000,
content: observedMessage.content,
participantIds: ["sender-1", "login-user-1"],
readStatus: 1,
messageStatus: 2,
unreadCount: 0,
firstObservedAt: new Date("2026-09-15T00:00:00.000Z"),
lastObservedAt: new Date("2026-09-15T00:00:00.000Z"),
};
const transaction = {
select: () => ({
from: (table: unknown) => ({
where: () => ({
limit: () => {
const rows =
table === onetalkConversation
? [
{
conversationId: "conversation-1",
conversationKind: "direct" as const,
historyGeneration: "initial",
},
]
: table === onetalkRenderedCardContent
? [{ content: renderedCardContent }]
: [];
const promise = Promise.resolve(rows);
return Object.assign(promise, { for: () => promise });
},
}),
}),
}),
insert: () => ({
values: () => ({
onConflictDoNothing: () => ({ returning: async () => [baseRow] }),
}),
}),
update: () => ({ set: () => ({ where: async () => [] }) }),
};
const database = {
transaction: async (run: (value: typeof transaction) => Promise<unknown>) =>
run(transaction),
};
const repository = createOneTalkRepository(database as never);
const results = await repository.guardedObserveMessages(
context,
[
{
historyGeneration: "initial",
observationSource: "live",
normalization: { ok: true, message: observedMessage },
},
],
{ assertValid: () => undefined },
);
assert.deepEqual(results, [
{
status: "accepted",
message: observedMessage,
renderedCardContent,
},
]);
});
test("advances only valid shared anchors and preserves incomplete outcomes", async () => { test("advances only valid shared anchors and preserves incomplete outcomes", async () => {
const emptyHarness = createRepositoryHarness(); const emptyHarness = createRepositoryHarness();
const emptyService = createOneTalkService(emptyHarness.repository); const emptyService = createOneTalkService(emptyHarness.repository);
+123 -2
View File
@@ -3,7 +3,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import type { OneTalkMessage } from "@trade-message-center/onetalk-contract"; import type {
OneTalkMessage,
OneTalkRenderedCardContent,
} from "@trade-message-center/onetalk-contract";
import { import {
createOneTalkReadService, createOneTalkReadService,
@@ -575,6 +578,124 @@ test("projects one persisted product fact identically and rejects raw-query JSON
); );
}); });
test("projects inquiry, product, and order supplements without changing their base kinds", () => {
const product = {
version: 1 as const,
kind: "product" as const,
sourceUrl:
"https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html",
productId: "1601456609478",
};
const cases: Array<{
content: OneTalkMessage["content"];
renderedCardContent: OneTalkRenderedCardContent;
}> = [
{
content: { version: 1 as const, kind: "inquiry" as const },
renderedCardContent: {
version: 1 as const,
kind: "rendered_inquiry" as const,
product: { imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" },
purchaseQuantity: { value: "100", unit: "pieces" },
requirementText: "Need delivery this week",
inquiryReference: "inquiry-1",
actions: [{ label: "Reply", available: true }],
},
},
{
content: product,
renderedCardContent: {
version: 1 as const,
kind: "rendered_product" as const,
product: {
imageUrl: "https://img.alicdn.com/item.jpg",
title: "Widget",
sourceUrl: product.sourceUrl,
productId: product.productId,
},
priceDisplay: "US $10.00",
minimumOrder: { value: "1", unit: "piece" },
serviceBadges: ["Fast dispatch"],
},
},
{
content: {
version: 1 as const,
kind: "order" as const,
orderId: "order-1",
bizCode: null,
contractId: null,
id: null,
tenant: null,
orderAmount: 10,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "paid",
actions: [],
},
renderedCardContent: {
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" },
},
},
];
for (const entry of cases) {
const persisted = message({
content: entry.content,
renderedCardContent: entry.renderedCardContent,
});
const history = projectCenterMessage(persisted);
const live = toOneTalkCenterMessage(persisted, entry.renderedCardContent);
assert.deepEqual(history, live);
assert.equal(history.content.kind, entry.content.kind);
assert.equal(history.content.kind.startsWith("rendered_"), false);
}
});
test("rejects a rendered-card supplement with a different base kind or product identity", () => {
const product = {
version: 1 as const,
kind: "product" as const,
sourceUrl:
"https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609479.html",
productId: "1601456609479",
};
const renderedProduct = {
version: 1 as const,
kind: "rendered_product" as const,
product: {
imageUrl: "https://img.alicdn.com/item.jpg",
title: "Widget",
sourceUrl:
"https://chinese.alibaba.com/product-detail/HAGO-Men-s-Breathable-Mid-Rise-1601456609478.html",
productId: "1601456609478",
},
priceDisplay: "US $10.00",
minimumOrder: { value: "1", unit: "piece" },
serviceBadges: ["Fast dispatch"],
};
assert.throws(
() => toOneTalkCenterMessage(message({ content: product }), renderedProduct),
/identity does not match/,
);
assert.throws(() => toOneTalkCenterMessage(message(), renderedProduct), /kind does not match/);
});
test("rejects malformed rendered-card JSONB at the read boundary", () => { test("rejects malformed rendered-card JSONB at the read boundary", () => {
assert.throws( assert.throws(
() => () =>
@@ -585,7 +706,7 @@ test("rejects malformed rendered-card JSONB at the read boundary", () => {
kind: "rendered_order", kind: "rendered_order",
} as never, } as never,
}), }),
/Invalid persisted OneTalk message content/, /Invalid persisted OneTalk rendered-card supplement/,
); );
}); });
@@ -46,7 +46,24 @@ const message = {
participantIds: ["sender-1", "receiver-1"], participantIds: ["sender-1", "receiver-1"],
direction: "received" as const, direction: "received" as const,
sentAtMs: 99, sentAtMs: 99,
content, readStatus: 1,
messageStatus: 2,
unreadCount: 0,
content: {
version: 1 as const,
kind: "order" as const,
orderId: "order-1",
bizCode: null,
contractId: null,
id: null,
tenant: null,
orderAmount: 1,
orderAmountCurrency: "USD",
paymentAmount: 1,
paymentAmountCurrency: "USD",
statusMessageKey: "paid",
actions: [],
},
}; };
test("ACKs an independently committed card when its base message has not arrived", async () => { test("ACKs an independently committed card when its base message has not arrived", async () => {
@@ -55,7 +72,11 @@ test("ACKs an independently committed card when its base message has not arrived
const canonical = {} as never; const canonical = {} as never;
const flow = createOneTalkRenderedCardFlow({ const flow = createOneTalkRenderedCardFlow({
service: { service: {
ingest: async () => ({ status: "accepted" as const, content, message: null }), ingest: async () => ({
status: "accepted" as const,
renderedCardContent: content,
message: null,
}),
}, },
registry: { registry: {
getCanonicalConnection: () => canonical, getCanonicalConnection: () => canonical,
@@ -97,7 +118,13 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) {
const published: unknown[] = []; const published: unknown[] = [];
const flow = createOneTalkRenderedCardFlow({ const flow = createOneTalkRenderedCardFlow({
service: { service: {
ingest: async () => ({ status, content, message }), ingest: async () => {
if (status === "accepted")
return { status, renderedCardContent: content, message };
if (status === "duplicate")
return { status, renderedCardContent: content, message };
return { status, renderedCardContent: content, message };
},
}, },
registry: { registry: {
getCanonicalConnection: () => canonical, getCanonicalConnection: () => canonical,
@@ -137,7 +164,24 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) {
assert.equal(published.length, status === "accepted" ? 1 : 0); assert.equal(published.length, status === "accepted" ? 1 : 0);
if (status === "accepted") { if (status === "accepted") {
assert.deepEqual(published[0], { assert.deepEqual(published[0], {
message, message: {
messageId: message.messageId,
conversationId: message.conversationId,
senderId: message.senderId,
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,
},
},
requestId: "request-1", requestId: "request-1",
scope: { scope: {
mindUserId: "mind-user-1", mindUserId: "mind-user-1",
@@ -55,7 +55,7 @@ test("stores a rendered-card supplement when its base message is absent", async
receivedAt: new Date("2026-09-15T00:00:00.000Z"), receivedAt: new Date("2026-09-15T00:00:00.000Z"),
}); });
assert.deepEqual(result, { status: "accepted", content, message: null }); assert.deepEqual(result, { status: "accepted", renderedCardContent: content, message: null });
assert.deepEqual(inserted, [ assert.deepEqual(inserted, [
{ {
channelAccountId: "account-1", channelAccountId: "account-1",
+108 -12
View File
@@ -110,6 +110,37 @@ const message = (messageId = "message-1"): OneTalkMessage => {
}; };
}; };
const orderMessage = (messageId = "message-1"): OneTalkMessage => ({
...message(messageId),
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: [],
},
});
const renderedOrderSupplement = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Confirmed 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 },
};
const centerMessage = (value: OneTalkMessage): OneTalkCenterMessage => ({ const centerMessage = (value: OneTalkMessage): OneTalkCenterMessage => ({
messageId: value.messageId, messageId: value.messageId,
conversationId: value.conversationId, conversationId: value.conversationId,
@@ -424,9 +455,10 @@ const closeApp = async (
}; };
test("executes a wire assertion for every declared OneTalk client frame", async () => { test("executes a wire assertion for every declared OneTalk client frame", async () => {
const service = createService(async (_context, _source, observedMessage) => ({ const service = createService(async (_context, source, observedMessage) => ({
status: "accepted" as const, status: "accepted" as const,
message: observedMessage, message: observedMessage,
...(source === "send_confirmation" ? { renderedCardContent: renderedOrderSupplement } : {}),
})); }));
const renderedCardInputs: unknown[] = []; const renderedCardInputs: unknown[] = [];
const app = createApp(testConfig, { const app = createApp(testConfig, {
@@ -456,11 +488,8 @@ test("executes a wire assertion for every declared OneTalk client frame", async
renderedCardInputs.push(input); renderedCardInputs.push(input);
return { return {
status: "accepted" as const, status: "accepted" as const,
content: input.observation.content, renderedCardContent: input.observation.content,
message: { message: orderMessage(input.observation.messageId),
...centerMessage(message(input.observation.messageId)),
content: input.observation.content,
},
}; };
}, },
}, },
@@ -850,14 +879,39 @@ test("executes a wire assertion for every declared OneTalk client frame", async
}); });
assert.deepEqual(renderedCardFrames[1]?.payload, { assert.deepEqual(renderedCardFrames[1]?.payload, {
message: { message: {
...centerMessage(message("matrix-rendered-card-message")), ...centerMessage(orderMessage("matrix-rendered-card-message")),
content: renderedCardContent, 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 },
},
}, },
}); });
assert.equal(renderedCardInputs.length, 1); assert.equal(renderedCardInputs.length, 1);
recordCoverage("rendered.card.observed", renderedCardFrames); recordCoverage("rendered.card.observed", renderedCardFrames);
const sentMessage = { ...message("matrix-sent"), direction: "sent" as const }; const sentMessage = { ...orderMessage("matrix-sent"), direction: "sent" as const };
const confirmationFrames = nextMessages(mind, 3); const confirmationFrames = nextMessages(mind, 3);
plugin.send( plugin.send(
JSON.stringify({ JSON.stringify({
@@ -879,11 +933,30 @@ test("executes a wire assertion for every declared OneTalk client frame", async
CLIENT_FRAME_COVERAGE_BASELINES["send.confirmation"], CLIENT_FRAME_COVERAGE_BASELINES["send.confirmation"],
); );
const sendResult = sentConfirmationFrames.find((frame) => frame.type === "send.result"); const sendResult = sentConfirmationFrames.find((frame) => frame.type === "send.result");
const messageCreated = sentConfirmationFrames.find(
(frame) => frame.type === "message.created",
);
if (!sendResult) assert.fail("send confirmation must produce a send.result"); if (!sendResult) assert.fail("send confirmation must produce a send.result");
if (!messageCreated) assert.fail("send confirmation must produce a message.created frame");
assert.deepEqual(sendResult.payload, { assert.deepEqual(sendResult.payload, {
status: "confirmed_sent", status: "confirmed_sent",
message: centerMessage(sentMessage), message: centerMessage(sentMessage),
}); });
assert.deepEqual(messageCreated.payload, {
message: {
...centerMessage(sentMessage),
content: {
...sentMessage.content,
title: renderedOrderSupplement.title,
products: renderedOrderSupplement.products,
productCount: renderedOrderSupplement.productCount,
status: renderedOrderSupplement.status,
payment: renderedOrderSupplement.payment,
delivery: renderedOrderSupplement.delivery,
action: renderedOrderSupplement.action,
},
},
});
const decodedSendResult = decodeOneTalkFrame(sendResult); const decodedSendResult = decodeOneTalkFrame(sendResult);
assert.equal(decodedSendResult.ok, true); assert.equal(decodedSendResult.ok, true);
recordCoverage("send.confirmation", sentConfirmationFrames); recordCoverage("send.confirmation", sentConfirmationFrames);
@@ -1720,13 +1793,36 @@ test("publishes an existing rendered-card supplement when its base live message
const acknowledgement = nextMessage(plugin); const acknowledgement = nextMessage(plugin);
const created = nextFrame(mind, (value) => value.type === "message.created"); const created = nextFrame(mind, (value) => value.type === "message.created");
plugin.send(JSON.stringify(observedFrame("late-base-message", message("late-message")))); plugin.send(
JSON.stringify(observedFrame("late-base-message", orderMessage("late-message"))),
);
assert.equal((await acknowledgement).type, "message.ack"); assert.equal((await acknowledgement).type, "message.ack");
assert.deepEqual((await created).payload, { assert.deepEqual((await created).payload, {
message: { message: {
...centerMessage(message("late-message")), ...centerMessage(orderMessage("late-message")),
content: renderedCardContent, 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: "Late 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 },
},
}, },
}); });
} finally { } finally {
+147 -6
View File
@@ -3,7 +3,25 @@
import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts"; import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts"; import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts";
import { isOneTalkProductReference } from "./product-url.ts"; import { isOneTalkProductReference } from "./product-url.ts";
import { isOneTalkRenderedCardContent, type OneTalkRenderedCardContent } from "./rendered-cards.ts"; import {
isOneTalkRenderedCardAction,
isOneTalkRenderedCardDelivery,
isOneTalkRenderedCardIdentifier,
isOneTalkRenderedCardImage,
isOneTalkRenderedCardImageUrl,
isOneTalkRenderedCardOrderAction,
isOneTalkRenderedCardOrderStatus,
isOneTalkRenderedCardPayment,
isOneTalkRenderedCardQuantity,
isOneTalkRenderedCardText,
type OneTalkRenderedCardAction,
type OneTalkRenderedCardDelivery,
type OneTalkRenderedCardImage,
type OneTalkRenderedCardOrderAction,
type OneTalkRenderedCardOrderStatus,
type OneTalkRenderedCardPayment,
type OneTalkRenderedCardQuantity,
} from "./rendered-cards.ts";
export const ONETALK_CONTENT_VERSION = 1 as const; export const ONETALK_CONTENT_VERSION = 1 as const;
export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3; export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3;
@@ -103,6 +121,35 @@ export type OneTalkProductContent = {
productId: string; productId: string;
}; };
/** Mind 读取端可选公开的询盘卡展示补全;基础 kind 永远保持 inquiry。 */
export type OneTalkCenterInquiryContent = OneTalkInquiryContent & {
product?: OneTalkRenderedCardImage;
purchaseQuantity?: OneTalkRenderedCardQuantity;
requirementText?: string;
inquiryReference?: string;
actions?: OneTalkRenderedCardAction[];
};
/** Mind 读取端可选公开的商品卡展示补全;基础身份不由补全覆盖。 */
export type OneTalkCenterProductContent = OneTalkProductContent & {
title?: string;
imageUrl?: string | null;
priceDisplay?: string;
minimumOrder?: OneTalkRenderedCardQuantity;
serviceBadges?: string[];
};
/** Mind 读取端可选公开的订单卡展示补全;原始订单字段保持完整。 */
export type OneTalkCenterOrderContent = OneTalkOrderContent & {
title?: string;
products?: OneTalkRenderedCardImage[];
productCount?: number;
status?: OneTalkRenderedCardOrderStatus;
payment?: OneTalkRenderedCardPayment;
delivery?: OneTalkRenderedCardDelivery;
action?: OneTalkRenderedCardOrderAction;
};
export type OneTalkMessageContent = export type OneTalkMessageContent =
| OneTalkTextContent | OneTalkTextContent
| OneTalkImageContent | OneTalkImageContent
@@ -113,9 +160,14 @@ export type OneTalkMessageContent =
| OneTalkProductContent; | OneTalkProductContent;
export type OneTalkCenterMessageContent = export type OneTalkCenterMessageContent =
| OneTalkMessageContent | Exclude<
| OneTalkBusinessCardViewContent OneTalkMessageContent,
| OneTalkRenderedCardContent; OneTalkInquiryContent | OneTalkOrderContent | OneTalkProductContent
>
| OneTalkCenterInquiryContent
| OneTalkCenterOrderContent
| OneTalkCenterProductContent
| OneTalkBusinessCardViewContent;
/** Mind HTTP 会话与 conversation.updated 共用的公开会话读取模型。 */ /** Mind HTTP 会话与 conversation.updated 共用的公开会话读取模型。 */
export type CenterConversation = { export type CenterConversation = {
@@ -230,6 +282,15 @@ const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]):
); );
}; };
const hasOnlyKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean =>
Object.keys(value).every((key) => keys.includes(key));
const withoutKeys = (
value: Record<string, unknown>,
keys: readonly string[],
): Record<string, unknown> =>
Object.fromEntries(Object.entries(value).filter(([key]) => !keys.includes(key)));
const isNonBlankString = (value: unknown, maximumLength: number): value is string => { const isNonBlankString = (value: unknown, maximumLength: number): value is string => {
return ( return (
typeof value === "string" && typeof value === "string" &&
@@ -501,6 +562,82 @@ const isOneTalkProductContent = (
return true; return true;
}; };
const CENTER_INQUIRY_SUPPLEMENT_KEYS = [
"product",
"purchaseQuantity",
"requirementText",
"inquiryReference",
"actions",
] as const;
const CENTER_PRODUCT_SUPPLEMENT_KEYS = [
"title",
"imageUrl",
"priceDisplay",
"minimumOrder",
"serviceBadges",
] as const;
const CENTER_ORDER_SUPPLEMENT_KEYS = [
"title",
"products",
"productCount",
"status",
"payment",
"delivery",
"action",
] as const;
const isOptional = <Value>(
value: unknown,
guard: (candidate: unknown) => candidate is Value,
): boolean => value === undefined || guard(value);
const isOneTalkCenterInquiryContent = (
value: Record<string, unknown>,
): value is OneTalkCenterInquiryContent =>
hasOnlyKeys(value, [...CONTENT_KEYS.inquiry, ...CENTER_INQUIRY_SUPPLEMENT_KEYS]) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "inquiry" &&
isOptional(value.product, isOneTalkRenderedCardImage) &&
isOptional(value.purchaseQuantity, isOneTalkRenderedCardQuantity) &&
isOptional(value.requirementText, isOneTalkRenderedCardText) &&
isOptional(value.inquiryReference, isOneTalkRenderedCardIdentifier) &&
(value.actions === undefined ||
(Array.isArray(value.actions) &&
value.actions.length <= MAX_ORDER_ACTIONS &&
value.actions.every(isOneTalkRenderedCardAction)));
const isOneTalkCenterProductContent = (
value: Record<string, unknown>,
): value is OneTalkCenterProductContent =>
hasOnlyKeys(value, [...CONTENT_KEYS.product, ...CENTER_PRODUCT_SUPPLEMENT_KEYS]) &&
isOneTalkProductContent(withoutKeys(value, CENTER_PRODUCT_SUPPLEMENT_KEYS)) &&
isOptional(value.title, isOneTalkRenderedCardText) &&
(value.imageUrl === undefined ||
value.imageUrl === null ||
isOneTalkRenderedCardImageUrl(value.imageUrl)) &&
isOptional(value.priceDisplay, isOneTalkRenderedCardText) &&
isOptional(value.minimumOrder, isOneTalkRenderedCardQuantity) &&
(value.serviceBadges === undefined ||
(Array.isArray(value.serviceBadges) &&
value.serviceBadges.length <= MAX_ORDER_ACTIONS &&
value.serviceBadges.every(isOneTalkRenderedCardText)));
const isOneTalkCenterOrderContent = (
value: Record<string, unknown>,
): value is OneTalkCenterOrderContent =>
hasOnlyKeys(value, [...CONTENT_KEYS.order, ...CENTER_ORDER_SUPPLEMENT_KEYS]) &&
isOneTalkOrderContent(withoutKeys(value, CENTER_ORDER_SUPPLEMENT_KEYS)) &&
isOptional(value.title, isOneTalkRenderedCardText) &&
(value.products === undefined ||
(Array.isArray(value.products) &&
value.products.length <= MAX_ORDER_ACTIONS &&
value.products.every(isOneTalkRenderedCardImage))) &&
(value.productCount === undefined || isNonNegativeSafeInteger(value.productCount)) &&
isOptional(value.status, isOneTalkRenderedCardOrderStatus) &&
isOptional(value.payment, isOneTalkRenderedCardPayment) &&
isOptional(value.delivery, isOneTalkRenderedCardDelivery) &&
isOptional(value.action, isOneTalkRenderedCardOrderAction);
/** 解码 exact-shape 的已归一化 OneTalk 消息内容。 */ /** 解码 exact-shape 的已归一化 OneTalk 消息内容。 */
export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageContentDecodeResult => { export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageContentDecodeResult => {
if (!isPlainRecord(value) || value.version !== ONETALK_CONTENT_VERSION) return { ok: false }; if (!isPlainRecord(value) || value.version !== ONETALK_CONTENT_VERSION) return { ok: false };
@@ -539,8 +676,12 @@ export const isOneTalkCenterMessageContent = (
value: unknown, value: unknown,
): value is OneTalkCenterMessageContent => { ): value is OneTalkCenterMessageContent => {
if (isOneTalkMessageContent(value)) return true; if (isOneTalkMessageContent(value)) return true;
if (isOneTalkRenderedCardContent(value)) return true; if (!isPlainRecord(value)) return false;
return isPlainRecord(value) && isOneTalkBusinessCardViewContent(value); if (isOneTalkBusinessCardViewContent(value)) return true;
if (value.kind === "inquiry") return isOneTalkCenterInquiryContent(value);
if (value.kind === "product") return isOneTalkCenterProductContent(value);
if (value.kind === "order") return isOneTalkCenterOrderContent(value);
return false;
}; };
/** 判断值是否为 exact-shape 的 Mind 公开会话读取模型。 */ /** 判断值是否为 exact-shape 的 Mind 公开会话读取模型。 */
+12
View File
@@ -130,7 +130,15 @@ export {
createOneTalkRenderedCardContentFingerprint, createOneTalkRenderedCardContentFingerprint,
createOneTalkRenderedCardObservedFrame, createOneTalkRenderedCardObservedFrame,
isOneTalkRenderedCardContent, isOneTalkRenderedCardContent,
isOneTalkRenderedCardImage,
isOneTalkRenderedCardImageUrl, isOneTalkRenderedCardImageUrl,
isOneTalkRenderedCardIdentifier,
isOneTalkRenderedCardQuantity,
isOneTalkRenderedCardAction,
isOneTalkRenderedCardOrderStatus,
isOneTalkRenderedCardPayment,
isOneTalkRenderedCardDelivery,
isOneTalkRenderedCardOrderAction,
isOneTalkRenderedCardText, isOneTalkRenderedCardText,
isSameOneTalkRenderedCardContent, isSameOneTalkRenderedCardContent,
isValidOneTalkRenderedCardPayload, isValidOneTalkRenderedCardPayload,
@@ -141,6 +149,10 @@ export type {
OneTalkRenderedCardAckFrame, OneTalkRenderedCardAckFrame,
OneTalkRenderedCardContent, OneTalkRenderedCardContent,
OneTalkRenderedCardImage, OneTalkRenderedCardImage,
OneTalkRenderedCardOrderStatus,
OneTalkRenderedCardPayment,
OneTalkRenderedCardDelivery,
OneTalkRenderedCardOrderAction,
OneTalkRenderedCardObservation, OneTalkRenderedCardObservation,
OneTalkRenderedCardObservedFrame, OneTalkRenderedCardObservedFrame,
OneTalkRenderedCardQuantity, OneTalkRenderedCardQuantity,
+73 -38
View File
@@ -18,6 +18,14 @@ export type OneTalkRenderedCardAckStatus = (typeof ONETALK_RENDERED_CARD_ACK_STA
export type OneTalkRenderedCardImage = { imageUrl: string | null; title: string }; export type OneTalkRenderedCardImage = { imageUrl: string | null; title: string };
export type OneTalkRenderedCardQuantity = { value: string; unit: string }; export type OneTalkRenderedCardQuantity = { value: string; unit: string };
export type OneTalkRenderedCardAction = { label: string; available: boolean }; export type OneTalkRenderedCardAction = { label: string; available: boolean };
export type OneTalkRenderedCardOrderStatus = { code: string | null; text: string };
export type OneTalkRenderedCardPayment = { totalDisplay: string; discountDisplay: string | null };
export type OneTalkRenderedCardDelivery = {
shippingAddress: string;
methodLabel: string | null;
dateLabel: string | null;
};
export type OneTalkRenderedCardOrderAction = { label: string | null; status: string | null };
export type OneTalkRenderedInquiryContent = { export type OneTalkRenderedInquiryContent = {
version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION; version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION;
kind: "rendered_inquiry"; kind: "rendered_inquiry";
@@ -41,10 +49,10 @@ export type OneTalkRenderedOrderContent = {
title: string; title: string;
products: OneTalkRenderedCardImage[]; products: OneTalkRenderedCardImage[];
productCount: number; productCount: number;
status: { code: string | null; text: string }; status: OneTalkRenderedCardOrderStatus;
payment: { totalDisplay: string; discountDisplay: string | null }; payment: OneTalkRenderedCardPayment;
delivery: { shippingAddress: string; methodLabel: string | null; dateLabel: string | null }; delivery: OneTalkRenderedCardDelivery;
action: { label: string | null; status: string | null }; action: OneTalkRenderedCardOrderAction;
}; };
export type OneTalkRenderedCardContent = export type OneTalkRenderedCardContent =
| OneTalkRenderedInquiryContent | OneTalkRenderedInquiryContent
@@ -101,7 +109,8 @@ const isRenderedCardText = (value: unknown, maximum = MAX_TEXT_LENGTH): value is
export const isOneTalkRenderedCardText = (value: unknown): value is string => export const isOneTalkRenderedCardText = (value: unknown): value is string =>
isRenderedCardText(value); isRenderedCardText(value);
const isIdentifier = (value: unknown): value is string => /** 验证 rendered-card 中的受限稳定标识符。 */
export const isOneTalkRenderedCardIdentifier = (value: unknown): value is string =>
isRenderedCardText(value, MAX_IDENTIFIER_LENGTH); isRenderedCardText(value, MAX_IDENTIFIER_LENGTH);
const isSafeNonNegativeInteger = (value: unknown): value is number => const isSafeNonNegativeInteger = (value: unknown): value is number =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0; typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
@@ -127,22 +136,62 @@ export const isOneTalkRenderedCardImageUrl = (value: unknown): value is string =
const isNullableImageUrl = (value: unknown): value is string | null => const isNullableImageUrl = (value: unknown): value is string | null =>
value === null || isOneTalkRenderedCardImageUrl(value); value === null || isOneTalkRenderedCardImageUrl(value);
const isCardImage = (value: unknown): value is OneTalkRenderedCardImage => /** 验证可安全公开的 rendered-card 图片展示值。 */
export const isOneTalkRenderedCardImage = (value: unknown): value is OneTalkRenderedCardImage =>
isPlainRecord(value) && isPlainRecord(value) &&
hasExactKeys(value, ["imageUrl", "title"]) && hasExactKeys(value, ["imageUrl", "title"]) &&
isNullableImageUrl(value.imageUrl) && isNullableImageUrl(value.imageUrl) &&
isOneTalkRenderedCardText(value.title); isOneTalkRenderedCardText(value.title);
const isQuantity = (value: unknown): value is OneTalkRenderedCardQuantity => /** 验证可安全公开的 rendered-card 数量展示值。 */
export const isOneTalkRenderedCardQuantity = (
value: unknown,
): value is OneTalkRenderedCardQuantity =>
isPlainRecord(value) && isPlainRecord(value) &&
hasExactKeys(value, ["value", "unit"]) && hasExactKeys(value, ["value", "unit"]) &&
isOneTalkRenderedCardText(value.value) && isOneTalkRenderedCardText(value.value) &&
isOneTalkRenderedCardText(value.unit); isOneTalkRenderedCardText(value.unit);
const isAction = (value: unknown): value is OneTalkRenderedCardAction => /** 验证可安全公开的 rendered-card 操作展示值。 */
export const isOneTalkRenderedCardAction = (value: unknown): value is OneTalkRenderedCardAction =>
isPlainRecord(value) && isPlainRecord(value) &&
hasExactKeys(value, ["label", "available"]) && hasExactKeys(value, ["label", "available"]) &&
isOneTalkRenderedCardText(value.label) && isOneTalkRenderedCardText(value.label) &&
typeof value.available === "boolean"; typeof value.available === "boolean";
/** 验证可安全公开的 rendered-card 订单状态展示值。 */
export const isOneTalkRenderedCardOrderStatus = (
value: unknown,
): value is OneTalkRenderedCardOrderStatus =>
isPlainRecord(value) &&
hasExactKeys(value, ["code", "text"]) &&
(value.code === null || isOneTalkRenderedCardIdentifier(value.code)) &&
isOneTalkRenderedCardText(value.text);
/** 验证可安全公开的 rendered-card 支付展示值。 */
export const isOneTalkRenderedCardPayment = (value: unknown): value is OneTalkRenderedCardPayment =>
isPlainRecord(value) &&
hasExactKeys(value, ["totalDisplay", "discountDisplay"]) &&
isOneTalkRenderedCardText(value.totalDisplay) &&
(value.discountDisplay === null || isOneTalkRenderedCardText(value.discountDisplay));
/** 验证可安全公开的 rendered-card 配送展示值。 */
export const isOneTalkRenderedCardDelivery = (
value: unknown,
): value is OneTalkRenderedCardDelivery =>
isPlainRecord(value) &&
hasExactKeys(value, ["shippingAddress", "methodLabel", "dateLabel"]) &&
isOneTalkRenderedCardText(value.shippingAddress) &&
(value.methodLabel === null || isOneTalkRenderedCardText(value.methodLabel)) &&
(value.dateLabel === null || isOneTalkRenderedCardText(value.dateLabel));
/** 验证可安全公开的 rendered-card 订单动作展示值。 */
export const isOneTalkRenderedCardOrderAction = (
value: unknown,
): value is OneTalkRenderedCardOrderAction =>
isPlainRecord(value) &&
hasExactKeys(value, ["label", "status"]) &&
(value.label === null || isOneTalkRenderedCardText(value.label)) &&
(value.status === null || isOneTalkRenderedCardText(value.status));
const canonicalize = (value: unknown): string => { const canonicalize = (value: unknown): string => {
if (value === null || typeof value !== "object") return JSON.stringify(value); if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`; if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
@@ -193,13 +242,13 @@ export const isOneTalkRenderedCardContent = (
"inquiryReference", "inquiryReference",
"actions", "actions",
]) && ]) &&
isCardImage(value.product) && isOneTalkRenderedCardImage(value.product) &&
isQuantity(value.purchaseQuantity) && isOneTalkRenderedCardQuantity(value.purchaseQuantity) &&
isOneTalkRenderedCardText(value.requirementText) && isOneTalkRenderedCardText(value.requirementText) &&
isIdentifier(value.inquiryReference) && isOneTalkRenderedCardIdentifier(value.inquiryReference) &&
Array.isArray(value.actions) && Array.isArray(value.actions) &&
value.actions.length <= MAX_ITEMS && value.actions.length <= MAX_ITEMS &&
value.actions.every(isAction) value.actions.every(isOneTalkRenderedCardAction)
); );
} }
if (value.kind === "rendered_product") { if (value.kind === "rendered_product") {
@@ -222,7 +271,7 @@ export const isOneTalkRenderedCardContent = (
productId: product.productId, productId: product.productId,
}) && }) &&
isOneTalkRenderedCardText(value.priceDisplay) && isOneTalkRenderedCardText(value.priceDisplay) &&
isQuantity(value.minimumOrder) && isOneTalkRenderedCardQuantity(value.minimumOrder) &&
Array.isArray(value.serviceBadges) && Array.isArray(value.serviceBadges) &&
value.serviceBadges.length <= MAX_ITEMS && value.serviceBadges.length <= MAX_ITEMS &&
value.serviceBadges.every((badge) => isOneTalkRenderedCardText(badge)) value.serviceBadges.every((badge) => isOneTalkRenderedCardText(badge))
@@ -248,26 +297,12 @@ export const isOneTalkRenderedCardContent = (
isOneTalkRenderedCardText(value.title) && isOneTalkRenderedCardText(value.title) &&
Array.isArray(value.products) && Array.isArray(value.products) &&
value.products.length <= MAX_ITEMS && value.products.length <= MAX_ITEMS &&
value.products.every(isCardImage) && value.products.every(isOneTalkRenderedCardImage) &&
isSafeNonNegativeInteger(value.productCount) && isSafeNonNegativeInteger(value.productCount) &&
isPlainRecord(status) && isOneTalkRenderedCardOrderStatus(status) &&
hasExactKeys(status, ["code", "text"]) && isOneTalkRenderedCardPayment(payment) &&
(status.code === null || isIdentifier(status.code)) && isOneTalkRenderedCardDelivery(delivery) &&
isOneTalkRenderedCardText(status.text) && isOneTalkRenderedCardOrderAction(action)
isPlainRecord(payment) &&
hasExactKeys(payment, ["totalDisplay", "discountDisplay"]) &&
isOneTalkRenderedCardText(payment.totalDisplay) &&
(payment.discountDisplay === null ||
isOneTalkRenderedCardText(payment.discountDisplay)) &&
isPlainRecord(delivery) &&
hasExactKeys(delivery, ["shippingAddress", "methodLabel", "dateLabel"]) &&
isOneTalkRenderedCardText(delivery.shippingAddress) &&
(delivery.methodLabel === null || isOneTalkRenderedCardText(delivery.methodLabel)) &&
(delivery.dateLabel === null || isOneTalkRenderedCardText(delivery.dateLabel)) &&
isPlainRecord(action) &&
hasExactKeys(action, ["label", "status"]) &&
(action.label === null || isOneTalkRenderedCardText(action.label)) &&
(action.status === null || isOneTalkRenderedCardText(action.status))
); );
} }
return false; return false;
@@ -282,10 +317,10 @@ const isObservation = (value: unknown): value is OneTalkRenderedCardObservation
"contentFingerprint", "contentFingerprint",
"observedAtMs", "observedAtMs",
]) && ]) &&
isIdentifier(value.conversationId) && isOneTalkRenderedCardIdentifier(value.conversationId) &&
isIdentifier(value.messageId) && isOneTalkRenderedCardIdentifier(value.messageId) &&
isOneTalkRenderedCardContent(value.content) && isOneTalkRenderedCardContent(value.content) &&
isIdentifier(value.contentFingerprint) && isOneTalkRenderedCardIdentifier(value.contentFingerprint) &&
isSafeNonNegativeInteger(value.observedAtMs) && isSafeNonNegativeInteger(value.observedAtMs) &&
value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content); value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content);
@@ -303,9 +338,9 @@ export const isValidOneTalkRenderedCardPayload = (type: string, value: unknown):
"status", "status",
...(value.rejectionCode === undefined ? [] : ["rejectionCode"]), ...(value.rejectionCode === undefined ? [] : ["rejectionCode"]),
]) && ]) &&
isIdentifier(value.conversationId) && isOneTalkRenderedCardIdentifier(value.conversationId) &&
isIdentifier(value.messageId) && isOneTalkRenderedCardIdentifier(value.messageId) &&
isIdentifier(value.contentFingerprint) && isOneTalkRenderedCardIdentifier(value.contentFingerprint) &&
isSafeNonNegativeInteger(value.observedAtMs) && isSafeNonNegativeInteger(value.observedAtMs) &&
typeof value.status === "string" && typeof value.status === "string" &&
ONETALK_RENDERED_CARD_ACK_STATUSES.includes(value.status as OneTalkRenderedCardAckStatus) && ONETALK_RENDERED_CARD_ACK_STATUSES.includes(value.status as OneTalkRenderedCardAckStatus) &&
+106 -1
View File
@@ -52,6 +52,7 @@ import {
isOneTalkOutboundContent, isOneTalkOutboundContent,
isPlainRecord, isPlainRecord,
isOneTalkCenterMessage, isOneTalkCenterMessage,
isOneTalkCenterMessageContent,
isNextOneTalkConversationsDiscoveredFragment, isNextOneTalkConversationsDiscoveredFragment,
isOneTalkMessage, isOneTalkMessage,
isOneTalkMessageContent, isOneTalkMessageContent,
@@ -192,7 +193,28 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl
participantIds: ["sender-1", "receiver-1"], participantIds: ["sender-1", "receiver-1"],
direction: "received", direction: "received",
sentAtMs: 1_700_000_000_000, sentAtMs: 1_700_000_000_000,
content, content: {
version: 1,
kind: "order",
orderId: "order-1",
bizCode: null,
contractId: null,
id: null,
tenant: null,
orderAmount: 10,
orderAmountCurrency: "USD",
paymentAmount: 10,
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 }); assert.deepEqual(decodeOneTalkFrame(updated), { ok: true, frame: updated });
@@ -1397,6 +1419,89 @@ test("stores exact business-card markers and only exposes customer fields in pub
} }
}); });
test("projects rendered-card display facts onto their base Center content kinds", () => {
const renderedInquiry = {
version: 1 as const,
kind: "rendered_inquiry" as const,
product: { imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" },
purchaseQuantity: { value: "100", unit: "pieces" },
requirementText: "Need delivery this week",
inquiryReference: "inquiry-1",
actions: [{ label: "Reply", available: true }],
};
const renderedProduct = {
version: 1 as const,
kind: "rendered_product" as const,
product: {
imageUrl: "https://img.alicdn.com/item.jpg",
title: "Widget",
sourceUrl: productContent.sourceUrl,
productId: productContent.productId,
},
priceDisplay: "US $10.00",
minimumOrder: { value: "1", unit: "piece" },
serviceBadges: ["Fast dispatch"],
};
const renderedOrder = {
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 centerInquiry = {
...inquiryContent,
product: renderedInquiry.product,
purchaseQuantity: renderedInquiry.purchaseQuantity,
requirementText: renderedInquiry.requirementText,
inquiryReference: renderedInquiry.inquiryReference,
actions: renderedInquiry.actions,
};
const centerProduct = {
...productContent,
title: renderedProduct.product.title,
imageUrl: renderedProduct.product.imageUrl,
priceDisplay: renderedProduct.priceDisplay,
minimumOrder: renderedProduct.minimumOrder,
serviceBadges: renderedProduct.serviceBadges,
};
const centerOrder = {
...orderContent,
title: renderedOrder.title,
products: renderedOrder.products,
productCount: renderedOrder.productCount,
status: renderedOrder.status,
payment: renderedOrder.payment,
delivery: renderedOrder.delivery,
action: renderedOrder.action,
};
for (const content of [
inquiryContent,
productContent,
orderContent,
centerInquiry,
centerProduct,
centerOrder,
]) {
assert.equal(isOneTalkCenterMessageContent(content), true);
}
for (const content of [renderedInquiry, renderedProduct, renderedOrder]) {
assert.equal(isOneTalkCenterMessageContent(content), false);
}
for (const content of [
{ ...centerInquiry, unexpected: true },
{ ...centerProduct, minimumOrder: { value: "1" } },
{ ...centerOrder, status: { code: "paid" } },
]) {
assert.equal(isOneTalkCenterMessageContent(content), false);
}
});
test("normalizes raw product URLs and only accepts their exact query-free product facts", () => { test("normalizes raw product URLs and only accepts their exact query-free product facts", () => {
const rawUrl = `${productContent.sourceUrl}?chatToken=secret&targetEncrypt=encrypted`; const rawUrl = `${productContent.sourceUrl}?chatToken=secret&targetEncrypt=encrypted`;
assert.deepEqual(normalizeOneTalkProductUrl(rawUrl), { assert.deepEqual(normalizeOneTalkProductUrl(rawUrl), {