mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
@@ -36,7 +36,7 @@
|
||||
| [错误处理](./error-handling.md) | HTTP/WS 稳定错误映射与安全边界 | 已建立 |
|
||||
| [质量规范](./quality-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 独立契约 |
|
||||
| [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/浏览器联调另行验证 |
|
||||
|
||||
@@ -301,7 +301,7 @@ decodeOneTalkHistoryReadCursor(value) -> cursor | null
|
||||
- 会话只读取 `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 例外。
|
||||
- 历史 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" }` marker;read projection 先按同一 `channelAccountId + conversationId` 受限读取当前 profile,再以内存方式扩展为 `contactName`、`companyName`、`countryCode`、`avatarUrl` 四项 view 字段。没有 profile 时返回 marker,profile 部分缺失时对应字段为 `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" }` marker;read projection 先按同一 `channelAccountId + conversationId` 受限读取当前 profile,再以内存方式扩展为 `contactName`、`companyName`、`countryCode`、`avatarUrl` 四项 view 字段。没有 profile 时返回 marker,profile 部分缺失时对应字段为 `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 路由契约。
|
||||
- 插件 `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);
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
- Good:HTTP、`message.created` 和 `message.updated` 都调用同一个投影,订单公开为 `kind: "order"` 并可带 `status`/`payment`,内部 `rendered_order` 不离开服务端边界。
|
||||
- Base:基础 message 先到且没有 supplement 时,后续读取保留原始 shape;补全到达后只发一次同一基础 kind 的 `message.updated`。
|
||||
- Bad:repository 直接构造 Center message、每个 WebSocket flow 各自 merge 字段,或 batch accepted 分支遗漏既有 supplement;这些都会导致 HTTP/live 形状分叉。
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Contract:三种基础 kind 的无补全 exact shape、补全后的批准可选字段、unknown key/错误嵌套拒绝,以及 supplement kind/product identity 不一致拒绝。
|
||||
- Read/WebSocket:HTTP 与 `message.created`/`message.updated` 对同一事实完全一致;覆盖补全后到、补全先到后单条 live、补全先到后 batch live 和 send confirmation,断言公开 kind 永不为 `rendered_*`。
|
||||
- Repository:batch 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 固定协议内核与静态已认证路由
|
||||
|
||||
### 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": {}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<!-- @@@auto:current-status -->
|
||||
- **Active File**: `journal-1.md`
|
||||
- **Total Sessions**: 74
|
||||
- **Total Sessions**: 75
|
||||
- **Last Active**: 2026-09-15
|
||||
<!-- @@@/auto:current-status -->
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
<!-- @@@auto:active-documents -->
|
||||
| File | Lines | Status |
|
||||
|------|-------|--------|
|
||||
| `journal-1.md` | ~1613 | Active |
|
||||
| `journal-1.md` | ~1635 | Active |
|
||||
<!-- @@@/auto:active-documents -->
|
||||
|
||||
---
|
||||
@@ -29,6 +29,7 @@
|
||||
<!-- @@@auto:session-history -->
|
||||
| # | 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` |
|
||||
| 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` |
|
||||
|
||||
@@ -1611,3 +1611,25 @@ Enforced unique workspace context across Mind authorization HTTP and WebSocket f
|
||||
### Next Steps
|
||||
|
||||
- 修复既有 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**
|
||||
|
||||
@@ -2,10 +2,13 @@
|
||||
|
||||
import {
|
||||
isOneTalkCenterMessageContent,
|
||||
isOneTalkMessageContent,
|
||||
isOneTalkRenderedCardContent,
|
||||
type OneTalkHttpConversation,
|
||||
type OneTalkCenterMessage,
|
||||
type OneTalkMessage,
|
||||
type OneTalkCenterMessageContent,
|
||||
type OneTalkRenderedCardContent,
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
|
||||
import type {
|
||||
@@ -85,10 +88,73 @@ export const toHttpConversation = (
|
||||
|
||||
type OneTalkCenterMessageInput = Pick<
|
||||
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 = (
|
||||
@@ -110,13 +176,15 @@ const projectMessageContent = (
|
||||
|
||||
/** 从 normalized JSONB 事实和会话客户资料投影 Mind-facing 消息。 */
|
||||
export const toOneTalkCenterMessage = (
|
||||
message: OneTalkCenterMessageInputWithEffectiveContent,
|
||||
message: OneTalkCenterMessageInput,
|
||||
renderedCardContent?: OneTalkRenderedCardContent,
|
||||
customerProfile?: Pick<
|
||||
OneTalkReadCustomerProfile,
|
||||
"name" | "avatarUrl" | "countryCode" | "companyName"
|
||||
>,
|
||||
): OneTalkCenterMessage => {
|
||||
if (!isOneTalkCenterMessageContent(message.content)) {
|
||||
const content = supplementMessageContent(message.content, renderedCardContent);
|
||||
if (!isOneTalkCenterMessageContent(content)) {
|
||||
throw new Error("Invalid persisted OneTalk message content");
|
||||
}
|
||||
return {
|
||||
@@ -126,7 +194,7 @@ export const toOneTalkCenterMessage = (
|
||||
participantIds: [...message.participantIds],
|
||||
direction: message.direction,
|
||||
sentAtMs: message.sentAtMs,
|
||||
content: projectMessageContent(message.content, customerProfile),
|
||||
content: projectMessageContent(content, customerProfile),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -136,8 +204,4 @@ export const projectCenterMessage = (
|
||||
OneTalkReadCustomerProfile,
|
||||
"name" | "avatarUrl" | "countryCode" | "companyName"
|
||||
>,
|
||||
): OneTalkCenterMessage =>
|
||||
toOneTalkCenterMessage(
|
||||
{ ...row, content: row.renderedCardContent ?? row.content },
|
||||
customerProfile,
|
||||
);
|
||||
): OneTalkCenterMessage => toOneTalkCenterMessage(row, row.renderedCardContent, customerProfile);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// 定义 OneTalk 渲染卡片补全的服务端领域边界
|
||||
|
||||
import type {
|
||||
OneTalkCenterMessage,
|
||||
OneTalkMessage,
|
||||
OneTalkRenderedCardContent,
|
||||
OneTalkRenderedCardObservation,
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
@@ -10,18 +10,18 @@ import type { OneTalkCommitGuard } from "./model.ts";
|
||||
export type OneTalkRenderedCardStoreResult =
|
||||
| {
|
||||
status: "accepted";
|
||||
content: OneTalkRenderedCardContent;
|
||||
message: OneTalkCenterMessage | null;
|
||||
renderedCardContent: OneTalkRenderedCardContent;
|
||||
message: OneTalkMessage | null;
|
||||
}
|
||||
| {
|
||||
status: "duplicate";
|
||||
content: OneTalkRenderedCardContent;
|
||||
message: OneTalkCenterMessage | null;
|
||||
renderedCardContent: OneTalkRenderedCardContent;
|
||||
message: OneTalkMessage | null;
|
||||
}
|
||||
| {
|
||||
status: "conflict";
|
||||
content: OneTalkRenderedCardContent;
|
||||
message: OneTalkCenterMessage | null;
|
||||
renderedCardContent: OneTalkRenderedCardContent;
|
||||
message: OneTalkMessage | null;
|
||||
};
|
||||
|
||||
export type OneTalkRenderedCardRepository = {
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { and, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
isSameOneTalkRenderedCardContent,
|
||||
type OneTalkCenterMessage,
|
||||
type OneTalkMessage,
|
||||
type OneTalkRenderedCardContent,
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
import type { Database } from "../database/index.ts";
|
||||
@@ -32,17 +32,17 @@ const baseMessageCondition = (input: {
|
||||
eq(onetalkMessage.conversationId, input.observation.conversationId),
|
||||
eq(onetalkMessage.messageId, input.observation.messageId),
|
||||
);
|
||||
const effectiveMessageFor = (
|
||||
row: typeof onetalkMessage.$inferSelect,
|
||||
content: OneTalkRenderedCardContent,
|
||||
): OneTalkCenterMessage => ({
|
||||
const baseMessageFor = (row: typeof onetalkMessage.$inferSelect): OneTalkMessage => ({
|
||||
messageId: row.messageId,
|
||||
conversationId: row.conversationId,
|
||||
senderId: row.senderId,
|
||||
participantIds: [...row.participantIds],
|
||||
direction: row.direction,
|
||||
sentAtMs: row.sentAtMs,
|
||||
content,
|
||||
content: row.content,
|
||||
readStatus: row.readStatus,
|
||||
messageStatus: row.messageStatus,
|
||||
unreadCount: row.unreadCount,
|
||||
});
|
||||
const existingBaseMessageFor = async (
|
||||
transaction: Parameters<Parameters<Database["transaction"]>[0]>[0],
|
||||
@@ -87,10 +87,8 @@ const store = async (
|
||||
input.commitGuard?.assertValid();
|
||||
return {
|
||||
status: "accepted",
|
||||
content: input.observation.content,
|
||||
message: baseMessage
|
||||
? effectiveMessageFor(baseMessage, input.observation.content)
|
||||
: null,
|
||||
renderedCardContent: input.observation.content,
|
||||
message: baseMessage ? baseMessageFor(baseMessage) : null,
|
||||
};
|
||||
}
|
||||
const saved = record.renderedCardContent as OneTalkRenderedCardContent;
|
||||
@@ -104,8 +102,8 @@ const store = async (
|
||||
input.commitGuard?.assertValid();
|
||||
return {
|
||||
status: "duplicate",
|
||||
content: saved,
|
||||
message: baseMessage ? effectiveMessageFor(baseMessage, saved) : null,
|
||||
renderedCardContent: saved,
|
||||
message: baseMessage ? baseMessageFor(baseMessage) : null,
|
||||
};
|
||||
}
|
||||
await transaction
|
||||
@@ -121,8 +119,8 @@ const store = async (
|
||||
input.commitGuard?.assertValid();
|
||||
return {
|
||||
status: "conflict",
|
||||
content: saved,
|
||||
message: baseMessage ? effectiveMessageFor(baseMessage, saved) : null,
|
||||
renderedCardContent: saved,
|
||||
message: baseMessage ? baseMessageFor(baseMessage) : null,
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -606,7 +606,17 @@ const guardedObserveMessages = async (
|
||||
)
|
||||
: 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type OneTalkErrorCode,
|
||||
type OneTalkRenderedCardObservedFrame,
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
import { toOneTalkCenterMessage } from "../../../onetalk/read-projection.ts";
|
||||
import type { OneTalkRenderedCardService, OneTalkSourceContext } from "../../../onetalk/index.ts";
|
||||
import type { OneTalkAuthenticatedClientFrame } from "../../authenticated-router.ts";
|
||||
import type { OneTalkConnectionRegistry, OneTalkRegisteredConnection } from "../../registry.ts";
|
||||
@@ -95,7 +96,7 @@ export const createOneTalkRenderedCardFlow = (options: {
|
||||
if (result.status !== "accepted" || result.message === null) return;
|
||||
guard.assertValid();
|
||||
await options.registry.publishMessageUpdated({
|
||||
message: result.message,
|
||||
message: toOneTalkCenterMessage(result.message, result.renderedCardContent),
|
||||
requestId: frame.requestId,
|
||||
scope: scopeFor(context),
|
||||
policyEpoch,
|
||||
|
||||
@@ -53,10 +53,7 @@ export const createOneTalkSendConfirmationFlow = (
|
||||
if (result.status === "accepted") {
|
||||
pending.commitGuard.assertValid();
|
||||
await options.registry.publishMessageCreated({
|
||||
message: toOneTalkCenterMessage({
|
||||
...result.message,
|
||||
content: result.renderedCardContent ?? result.message.content,
|
||||
}),
|
||||
message: toOneTalkCenterMessage(result.message, result.renderedCardContent),
|
||||
requestId: pending.frame.requestId,
|
||||
scope: pending.mind.mindScope,
|
||||
policyEpoch: pending.policyEpoch,
|
||||
|
||||
@@ -393,10 +393,10 @@ export const createOneTalkSyncFlows = (options: OneTalkSyncFlowsOptions): OneTal
|
||||
queued.guard.assertValid();
|
||||
const scope = scopeForContext(request.context);
|
||||
await options.publishMessageCreated({
|
||||
message: toOneTalkCenterMessage({
|
||||
...result.message,
|
||||
content: result.renderedCardContent ?? result.message.content,
|
||||
}),
|
||||
message: toOneTalkCenterMessage(
|
||||
result.message,
|
||||
result.renderedCardContent,
|
||||
),
|
||||
requestId: queued.frame.requestId,
|
||||
scope,
|
||||
policyEpoch: queued.policyEpoch,
|
||||
|
||||
@@ -9,11 +9,17 @@ import type {
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
|
||||
import {
|
||||
createOneTalkRepository,
|
||||
createOneTalkService,
|
||||
type OneTalkConversationState,
|
||||
type OneTalkRepository,
|
||||
type OneTalkSourceContext,
|
||||
} from "../src/onetalk/index.ts";
|
||||
import {
|
||||
onetalkConversation,
|
||||
onetalkMessage,
|
||||
onetalkRenderedCardContent,
|
||||
} from "../src/database/schema/onetalk.ts";
|
||||
|
||||
const context: OneTalkSourceContext = {
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const emptyHarness = createRepositoryHarness();
|
||||
const emptyService = createOneTalkService(emptyHarness.repository);
|
||||
|
||||
@@ -3,7 +3,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
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 {
|
||||
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", () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
@@ -585,7 +706,7 @@ test("rejects malformed rendered-card JSONB at the read boundary", () => {
|
||||
kind: "rendered_order",
|
||||
} as never,
|
||||
}),
|
||||
/Invalid persisted OneTalk message content/,
|
||||
/Invalid persisted OneTalk rendered-card supplement/,
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -46,7 +46,24 @@ const message = {
|
||||
participantIds: ["sender-1", "receiver-1"],
|
||||
direction: "received" as const,
|
||||
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 () => {
|
||||
@@ -55,7 +72,11 @@ test("ACKs an independently committed card when its base message has not arrived
|
||||
const canonical = {} as never;
|
||||
const flow = createOneTalkRenderedCardFlow({
|
||||
service: {
|
||||
ingest: async () => ({ status: "accepted" as const, content, message: null }),
|
||||
ingest: async () => ({
|
||||
status: "accepted" as const,
|
||||
renderedCardContent: content,
|
||||
message: null,
|
||||
}),
|
||||
},
|
||||
registry: {
|
||||
getCanonicalConnection: () => canonical,
|
||||
@@ -97,7 +118,13 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) {
|
||||
const published: unknown[] = [];
|
||||
const flow = createOneTalkRenderedCardFlow({
|
||||
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: {
|
||||
getCanonicalConnection: () => canonical,
|
||||
@@ -137,7 +164,24 @@ for (const status of ["accepted", "duplicate", "conflict"] as const) {
|
||||
assert.equal(published.length, status === "accepted" ? 1 : 0);
|
||||
if (status === "accepted") {
|
||||
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",
|
||||
scope: {
|
||||
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"),
|
||||
});
|
||||
|
||||
assert.deepEqual(result, { status: "accepted", content, message: null });
|
||||
assert.deepEqual(result, { status: "accepted", renderedCardContent: content, message: null });
|
||||
assert.deepEqual(inserted, [
|
||||
{
|
||||
channelAccountId: "account-1",
|
||||
|
||||
@@ -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 => ({
|
||||
messageId: value.messageId,
|
||||
conversationId: value.conversationId,
|
||||
@@ -424,9 +455,10 @@ const closeApp = 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,
|
||||
message: observedMessage,
|
||||
...(source === "send_confirmation" ? { renderedCardContent: renderedOrderSupplement } : {}),
|
||||
}));
|
||||
const renderedCardInputs: unknown[] = [];
|
||||
const app = createApp(testConfig, {
|
||||
@@ -456,11 +488,8 @@ test("executes a wire assertion for every declared OneTalk client frame", async
|
||||
renderedCardInputs.push(input);
|
||||
return {
|
||||
status: "accepted" as const,
|
||||
content: input.observation.content,
|
||||
message: {
|
||||
...centerMessage(message(input.observation.messageId)),
|
||||
content: input.observation.content,
|
||||
},
|
||||
renderedCardContent: input.observation.content,
|
||||
message: orderMessage(input.observation.messageId),
|
||||
};
|
||||
},
|
||||
},
|
||||
@@ -850,14 +879,39 @@ test("executes a wire assertion for every declared OneTalk client frame", async
|
||||
});
|
||||
assert.deepEqual(renderedCardFrames[1]?.payload, {
|
||||
message: {
|
||||
...centerMessage(message("matrix-rendered-card-message")),
|
||||
content: renderedCardContent,
|
||||
...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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(renderedCardInputs.length, 1);
|
||||
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);
|
||||
plugin.send(
|
||||
JSON.stringify({
|
||||
@@ -879,11 +933,30 @@ test("executes a wire assertion for every declared OneTalk client frame", async
|
||||
CLIENT_FRAME_COVERAGE_BASELINES["send.confirmation"],
|
||||
);
|
||||
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 (!messageCreated) assert.fail("send confirmation must produce a message.created frame");
|
||||
assert.deepEqual(sendResult.payload, {
|
||||
status: "confirmed_sent",
|
||||
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);
|
||||
assert.equal(decodedSendResult.ok, true);
|
||||
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 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.deepEqual((await created).payload, {
|
||||
message: {
|
||||
...centerMessage(message("late-message")),
|
||||
content: renderedCardContent,
|
||||
...centerMessage(orderMessage("late-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: "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 {
|
||||
|
||||
@@ -3,7 +3,25 @@
|
||||
import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
|
||||
import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.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_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3;
|
||||
@@ -103,6 +121,35 @@ export type OneTalkProductContent = {
|
||||
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 =
|
||||
| OneTalkTextContent
|
||||
| OneTalkImageContent
|
||||
@@ -113,9 +160,14 @@ export type OneTalkMessageContent =
|
||||
| OneTalkProductContent;
|
||||
|
||||
export type OneTalkCenterMessageContent =
|
||||
| OneTalkMessageContent
|
||||
| OneTalkBusinessCardViewContent
|
||||
| OneTalkRenderedCardContent;
|
||||
| Exclude<
|
||||
OneTalkMessageContent,
|
||||
OneTalkInquiryContent | OneTalkOrderContent | OneTalkProductContent
|
||||
>
|
||||
| OneTalkCenterInquiryContent
|
||||
| OneTalkCenterOrderContent
|
||||
| OneTalkCenterProductContent
|
||||
| OneTalkBusinessCardViewContent;
|
||||
|
||||
/** Mind HTTP 会话与 conversation.updated 共用的公开会话读取模型。 */
|
||||
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 => {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
@@ -501,6 +562,82 @@ const isOneTalkProductContent = (
|
||||
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 消息内容。 */
|
||||
export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageContentDecodeResult => {
|
||||
if (!isPlainRecord(value) || value.version !== ONETALK_CONTENT_VERSION) return { ok: false };
|
||||
@@ -539,8 +676,12 @@ export const isOneTalkCenterMessageContent = (
|
||||
value: unknown,
|
||||
): value is OneTalkCenterMessageContent => {
|
||||
if (isOneTalkMessageContent(value)) return true;
|
||||
if (isOneTalkRenderedCardContent(value)) return true;
|
||||
return isPlainRecord(value) && isOneTalkBusinessCardViewContent(value);
|
||||
if (!isPlainRecord(value)) return false;
|
||||
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 公开会话读取模型。 */
|
||||
|
||||
@@ -130,7 +130,15 @@ export {
|
||||
createOneTalkRenderedCardContentFingerprint,
|
||||
createOneTalkRenderedCardObservedFrame,
|
||||
isOneTalkRenderedCardContent,
|
||||
isOneTalkRenderedCardImage,
|
||||
isOneTalkRenderedCardImageUrl,
|
||||
isOneTalkRenderedCardIdentifier,
|
||||
isOneTalkRenderedCardQuantity,
|
||||
isOneTalkRenderedCardAction,
|
||||
isOneTalkRenderedCardOrderStatus,
|
||||
isOneTalkRenderedCardPayment,
|
||||
isOneTalkRenderedCardDelivery,
|
||||
isOneTalkRenderedCardOrderAction,
|
||||
isOneTalkRenderedCardText,
|
||||
isSameOneTalkRenderedCardContent,
|
||||
isValidOneTalkRenderedCardPayload,
|
||||
@@ -141,6 +149,10 @@ export type {
|
||||
OneTalkRenderedCardAckFrame,
|
||||
OneTalkRenderedCardContent,
|
||||
OneTalkRenderedCardImage,
|
||||
OneTalkRenderedCardOrderStatus,
|
||||
OneTalkRenderedCardPayment,
|
||||
OneTalkRenderedCardDelivery,
|
||||
OneTalkRenderedCardOrderAction,
|
||||
OneTalkRenderedCardObservation,
|
||||
OneTalkRenderedCardObservedFrame,
|
||||
OneTalkRenderedCardQuantity,
|
||||
|
||||
@@ -18,6 +18,14 @@ export type OneTalkRenderedCardAckStatus = (typeof ONETALK_RENDERED_CARD_ACK_STA
|
||||
export type OneTalkRenderedCardImage = { imageUrl: string | null; title: string };
|
||||
export type OneTalkRenderedCardQuantity = { value: string; unit: string };
|
||||
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 = {
|
||||
version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION;
|
||||
kind: "rendered_inquiry";
|
||||
@@ -41,10 +49,10 @@ export type OneTalkRenderedOrderContent = {
|
||||
title: string;
|
||||
products: OneTalkRenderedCardImage[];
|
||||
productCount: number;
|
||||
status: { code: string | null; text: string };
|
||||
payment: { totalDisplay: string; discountDisplay: string | null };
|
||||
delivery: { shippingAddress: string; methodLabel: string | null; dateLabel: string | null };
|
||||
action: { label: string | null; status: string | null };
|
||||
status: OneTalkRenderedCardOrderStatus;
|
||||
payment: OneTalkRenderedCardPayment;
|
||||
delivery: OneTalkRenderedCardDelivery;
|
||||
action: OneTalkRenderedCardOrderAction;
|
||||
};
|
||||
export type OneTalkRenderedCardContent =
|
||||
| OneTalkRenderedInquiryContent
|
||||
@@ -101,7 +109,8 @@ const isRenderedCardText = (value: unknown, maximum = MAX_TEXT_LENGTH): value is
|
||||
export const isOneTalkRenderedCardText = (value: unknown): value is string =>
|
||||
isRenderedCardText(value);
|
||||
|
||||
const isIdentifier = (value: unknown): value is string =>
|
||||
/** 验证 rendered-card 中的受限稳定标识符。 */
|
||||
export const isOneTalkRenderedCardIdentifier = (value: unknown): value is string =>
|
||||
isRenderedCardText(value, MAX_IDENTIFIER_LENGTH);
|
||||
const isSafeNonNegativeInteger = (value: unknown): value is number =>
|
||||
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 =>
|
||||
value === null || isOneTalkRenderedCardImageUrl(value);
|
||||
const isCardImage = (value: unknown): value is OneTalkRenderedCardImage =>
|
||||
/** 验证可安全公开的 rendered-card 图片展示值。 */
|
||||
export const isOneTalkRenderedCardImage = (value: unknown): value is OneTalkRenderedCardImage =>
|
||||
isPlainRecord(value) &&
|
||||
hasExactKeys(value, ["imageUrl", "title"]) &&
|
||||
isNullableImageUrl(value.imageUrl) &&
|
||||
isOneTalkRenderedCardText(value.title);
|
||||
const isQuantity = (value: unknown): value is OneTalkRenderedCardQuantity =>
|
||||
/** 验证可安全公开的 rendered-card 数量展示值。 */
|
||||
export const isOneTalkRenderedCardQuantity = (
|
||||
value: unknown,
|
||||
): value is OneTalkRenderedCardQuantity =>
|
||||
isPlainRecord(value) &&
|
||||
hasExactKeys(value, ["value", "unit"]) &&
|
||||
isOneTalkRenderedCardText(value.value) &&
|
||||
isOneTalkRenderedCardText(value.unit);
|
||||
const isAction = (value: unknown): value is OneTalkRenderedCardAction =>
|
||||
/** 验证可安全公开的 rendered-card 操作展示值。 */
|
||||
export const isOneTalkRenderedCardAction = (value: unknown): value is OneTalkRenderedCardAction =>
|
||||
isPlainRecord(value) &&
|
||||
hasExactKeys(value, ["label", "available"]) &&
|
||||
isOneTalkRenderedCardText(value.label) &&
|
||||
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 => {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
||||
@@ -193,13 +242,13 @@ export const isOneTalkRenderedCardContent = (
|
||||
"inquiryReference",
|
||||
"actions",
|
||||
]) &&
|
||||
isCardImage(value.product) &&
|
||||
isQuantity(value.purchaseQuantity) &&
|
||||
isOneTalkRenderedCardImage(value.product) &&
|
||||
isOneTalkRenderedCardQuantity(value.purchaseQuantity) &&
|
||||
isOneTalkRenderedCardText(value.requirementText) &&
|
||||
isIdentifier(value.inquiryReference) &&
|
||||
isOneTalkRenderedCardIdentifier(value.inquiryReference) &&
|
||||
Array.isArray(value.actions) &&
|
||||
value.actions.length <= MAX_ITEMS &&
|
||||
value.actions.every(isAction)
|
||||
value.actions.every(isOneTalkRenderedCardAction)
|
||||
);
|
||||
}
|
||||
if (value.kind === "rendered_product") {
|
||||
@@ -222,7 +271,7 @@ export const isOneTalkRenderedCardContent = (
|
||||
productId: product.productId,
|
||||
}) &&
|
||||
isOneTalkRenderedCardText(value.priceDisplay) &&
|
||||
isQuantity(value.minimumOrder) &&
|
||||
isOneTalkRenderedCardQuantity(value.minimumOrder) &&
|
||||
Array.isArray(value.serviceBadges) &&
|
||||
value.serviceBadges.length <= MAX_ITEMS &&
|
||||
value.serviceBadges.every((badge) => isOneTalkRenderedCardText(badge))
|
||||
@@ -248,26 +297,12 @@ export const isOneTalkRenderedCardContent = (
|
||||
isOneTalkRenderedCardText(value.title) &&
|
||||
Array.isArray(value.products) &&
|
||||
value.products.length <= MAX_ITEMS &&
|
||||
value.products.every(isCardImage) &&
|
||||
value.products.every(isOneTalkRenderedCardImage) &&
|
||||
isSafeNonNegativeInteger(value.productCount) &&
|
||||
isPlainRecord(status) &&
|
||||
hasExactKeys(status, ["code", "text"]) &&
|
||||
(status.code === null || isIdentifier(status.code)) &&
|
||||
isOneTalkRenderedCardText(status.text) &&
|
||||
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))
|
||||
isOneTalkRenderedCardOrderStatus(status) &&
|
||||
isOneTalkRenderedCardPayment(payment) &&
|
||||
isOneTalkRenderedCardDelivery(delivery) &&
|
||||
isOneTalkRenderedCardOrderAction(action)
|
||||
);
|
||||
}
|
||||
return false;
|
||||
@@ -282,10 +317,10 @@ const isObservation = (value: unknown): value is OneTalkRenderedCardObservation
|
||||
"contentFingerprint",
|
||||
"observedAtMs",
|
||||
]) &&
|
||||
isIdentifier(value.conversationId) &&
|
||||
isIdentifier(value.messageId) &&
|
||||
isOneTalkRenderedCardIdentifier(value.conversationId) &&
|
||||
isOneTalkRenderedCardIdentifier(value.messageId) &&
|
||||
isOneTalkRenderedCardContent(value.content) &&
|
||||
isIdentifier(value.contentFingerprint) &&
|
||||
isOneTalkRenderedCardIdentifier(value.contentFingerprint) &&
|
||||
isSafeNonNegativeInteger(value.observedAtMs) &&
|
||||
value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content);
|
||||
|
||||
@@ -303,9 +338,9 @@ export const isValidOneTalkRenderedCardPayload = (type: string, value: unknown):
|
||||
"status",
|
||||
...(value.rejectionCode === undefined ? [] : ["rejectionCode"]),
|
||||
]) &&
|
||||
isIdentifier(value.conversationId) &&
|
||||
isIdentifier(value.messageId) &&
|
||||
isIdentifier(value.contentFingerprint) &&
|
||||
isOneTalkRenderedCardIdentifier(value.conversationId) &&
|
||||
isOneTalkRenderedCardIdentifier(value.messageId) &&
|
||||
isOneTalkRenderedCardIdentifier(value.contentFingerprint) &&
|
||||
isSafeNonNegativeInteger(value.observedAtMs) &&
|
||||
typeof value.status === "string" &&
|
||||
ONETALK_RENDERED_CARD_ACK_STATUSES.includes(value.status as OneTalkRenderedCardAckStatus) &&
|
||||
|
||||
@@ -52,6 +52,7 @@ import {
|
||||
isOneTalkOutboundContent,
|
||||
isPlainRecord,
|
||||
isOneTalkCenterMessage,
|
||||
isOneTalkCenterMessageContent,
|
||||
isNextOneTalkConversationsDiscoveredFragment,
|
||||
isOneTalkMessage,
|
||||
isOneTalkMessageContent,
|
||||
@@ -192,7 +193,28 @@ test("strictly validates rendered-card frames, canonical fingerprints, and in-pl
|
||||
participantIds: ["sender-1", "receiver-1"],
|
||||
direction: "received",
|
||||
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 });
|
||||
@@ -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", () => {
|
||||
const rawUrl = `${productContent.sourceUrl}?chatToken=secret&targetEncrypt=encrypted`;
|
||||
assert.deepEqual(normalizeOneTalkProductUrl(rawUrl), {
|
||||
|
||||
Reference in New Issue
Block a user