mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
Merge pull request #52 from sinanyuntu/09-15-unify-onetalk-card-mind-types
统一 OneTalk 二次卡片 Mind 内容类型
This commit is contained in:
@@ -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" }` 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 路由契约。
|
- 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
|
||||||
|
|
||||||
|
- 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 固定协议内核与静态已认证路由
|
## 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": {}
|
||||||
|
}
|
||||||
@@ -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` |
|
||||||
|
|||||||
@@ -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**
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 公开会话读取模型。 */
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) &&
|
||||||
|
|||||||
@@ -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), {
|
||||||
|
|||||||
Reference in New Issue
Block a user