feat: normalize OneTalk business card content

This commit is contained in:
YBF
2026-09-11 19:23:32 +08:00
parent 3be0b3f44d
commit f92f00dca5
36 changed files with 2410 additions and 47 deletions
@@ -22,6 +22,15 @@ type OneTalkPageMessage =
type: "onetalk.page.observed";
batch: ObservedOneTalkMessage[];
historyProgress?: HistoryPageProgress;
diagnostics?: {
unsupportedSkippedCount: number;
invalidObservationCount: number;
anomalies: Array<{
code: OneTalkMediaAnomalyCode;
mediaKind: "image" | "file" | "card";
count: number;
}>;
};
}
| {
type: "onetalk.page.profile-observed";
@@ -64,7 +73,8 @@ const ONE_TALK_PAGE_PORT_NAME = "trade-message-center.onetalk.page";
### Ownership
- MAIN world 只拥有 OneTalk SDK 访问、页面事实采集和页面命令执行;不得持有 Bright WebSocket、认证凭证或扩展 IndexedDB。
- OneTalk raw `contentType``custom.data`、SDK envelope 和认证字段只能在 MAIN 内短暂存在;MAIN 唯一 decoder 必须先生成 shared `OneTalkMessageContent``text | image | file` v1 联合,才允许跨 bridge。
- OneTalk raw `contentType``custom.data`、SDK envelope 和认证字段只能在 MAIN 内短暂存在;MAIN 唯一 decoder 必须先生成 shared `OneTalkMessageContent``text | image | file | business_card | inquiry | order` v1 联合,才允许跨 bridge。业务卡只允许历史 SDK 完整 tuple 的白名单投影;原始正文、完整 `contact`/`params``sign`、加密标识和 token 不得跨 bridge。
- `onetalk.page.observed.diagnostics` 是可选 exact-shape 的安全计数投影:只允许非负 `unsupportedSkippedCount``invalidObservationCount`,以及无重复的 `{ code, mediaKind, count }` anomaly。`code` 只能是 shared `media_*``card_*` 白名单,`mediaKind` 只能是 `image``file``card`;不得放入原始 SDK 字段、失败输入或异常文本。
- ISOLATED Content Script 只拥有页面桥和 \`runtime.Port\`;不得解释业务 payload、保存同步状态或选择备用页面。
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、命令路由、上传编排和 IndexedDB 访问。
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
@@ -61,7 +61,7 @@ Bright WebSocket
- ISOLATED Content Script 只拥有页面桥和 runtime.Port;不得解释业务 payload、保存同步状态或选择备用页面。
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、command 路由、上传编排、IndexedDB 和状态投影。
- 共享页面消息契约由 page bridge model/decoder 唯一拥有;Bright wire frame 契约由 onetalk-contract 唯一拥有。
- `protocolVersion=5``content.version=1` 独立演进;跨 MAIN 的消息仅是 shared normalized `text | image | file` content。raw `custom.data`、顶层 `text/contentType` 不能到达 ISOLATED、Service Worker、IndexedDB 或 Bright。
- `protocolVersion=5``content.version=1` 独立演进;跨 MAIN 的消息仅是 shared normalized `text | image | file | business_card | inquiry | order` content。业务卡只允许已观察的 `sdk_flat_history` 完整联合条件产生:名片只含消息时点四项资料快照,询盘只有类别,订单只含批准的摘要投影。raw `custom.data`、顶层 `text/contentType`、卡片原始正文、`params``sign` 和完整 `contact` 不能到达 ISOLATED、Service Worker、IndexedDB 或 Bright。
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
- Bright 是联系人资料当前事实源;Mind 只提供页面/插件授权上下文,不接收 profile delivery,也不作为 Bright read model 的 profile projection owner。
- 每个跨层业务概念必须只有一个 owner:页面 command 结果先在页面边界形成,发送三态先在 contract/adapter 边界收窄,服务端事实只在 server ingest 中提交。
@@ -253,7 +253,69 @@ const direction = resolveDirection(senderId, participantIds, selfParticipant);
if (direction !== undefined) output.direction = direction;
```
## 7. Reading and change ownership
## 7. 历史 SDK 结构化业务卡
### 7.1 Scope / Trigger
当已观察的 OneTalk 历史扁平 SDK 条目需要纳入业务卡事实时,扩展可在 MAIN world 将其归一化为 `business_card``inquiry``order`。此场景只适用于 `sdk_flat_history`raw WebSocket、DOM 文本和普通卡片正文不属于证据来源,不能借此扩展实时采集。
### 7.2 Signatures
```ts
type OneTalkMessageContentKind =
| "text" | "image" | "file"
| "business_card" | "inquiry" | "order";
decodeOneTalkMessageContent(source: OneTalkMessageContentSource) ->
{ status: "decoded", content: OneTalkMessageContent }
| { status: "ignored" | "unsupported_skipped" }
| { status: "anomaly", code: OneTalkMediaAnomalyCode, mediaKind: "image" | "file" | "card" };
```
`apps/onetalk-contract/src/content.ts` 是这六个 kind、exact-shape guard 和跨层读取模型的唯一 owner。`message-observer/content-decoder.ts` 是历史 SDK tuple 分类、受控 Base64 摘要解码和白名单投影的唯一生产点。
### 7.3 Contracts
- 历史业务卡必须同时满足 `messageType="rec"``type=1``viewType=0``msgType=10010`,以及唯一的 `(subType, cardType)`:名片 `(57,1)`、询盘 `(50,6)`、订单 `(59,9)`。任一条件缺失均不分类。
- 名片只投影 `contactName``companyName``countryCode``avatarUrl` 的消息时点快照;它不能覆盖 `contact.profile.observed` 当前资料 ledger。
- 询盘只有 `{ version: 1, kind: "inquiry" }`;订单只投影关联 ID、金额/币种、状态键、动作键与可用 `payStep`。所有 ID 接受受限字符串、任意 safe integer 或 `null`
- Base64、UTF-8、JSON、大小、动作 `properties` 或任一 order schema 失败必须形成安全的 `card_*` anomaly;不得降级为 text、空 order、inquiry 或正常 unsupported。
- 仅 normalized content 可经 page bridge、IndexedDB、Bright、JSONB 与 Mind read 复用。`content` 原文、完整 `contact``params`、加密标识、`sign`、token 和未批准 URL 永不跨 MAIN。
### 7.4 Validation & Error Matrix
| 条件 | 必须结果 |
| --- | --- |
| 完整历史 tuple + exact-shape 白名单投影 | `decoded` 对应业务 kind |
| 同为 `msgType=10010` 但 subtype/cardType 不匹配 | 保留既有 file/unsupported 行为,不误分类 |
| 订单摘要非 Base64、非 UTF-8、非 JSON、过大或 schema/action properties 非对象 | `card_*` anomaly,不携带 raw 输入 |
| card kind 出现额外键或非法 URL/金额/ID | shared guard 拒绝,桥/服务端不接纳 |
| raw WebSocket 或 DOM 才有的字段 | 本期不采集,既有 raw decoder 不变 |
### 7.5 Good / Base / Bad Cases
- Good:完整订单 tuple 的摘要解码后只发送金额、币种、状态键、批准的 action 和关联 ID。
- Base:询盘完整匹配时只发送类别;没有被观察到的商品详情不会被补成空字符串。
- Bad:以 `msgType=10010` 或 DOM 标题猜测类别;把 `contact``params``sign` 或订单正文放进 bridge/frame/JSONB;用 `payStep: null` 掩盖非法 action properties。
### 7.6 Tests Required
- contract:六类 exact-shape、额外键、名片 URL、订单金额/ID/action 边界(包括负 safe integer)和旧 kind 回归。
- extension:三种完整 tuple、相邻 10010 卡、raw WebSocket 不变、每一种订单解码失败和敏感字段未越界。
- serverwire/domain/read round-trip、JSONB kind CHECK、提交 → ACK → publish 次序与旧 content 回归。
- harness:按 shared exact-shape 验证后,名片只展示快照、询盘只展示分类、订单只展示批准字段;真实 Chromium 历史卡与 PostgreSQL migration 未运行时单独标记 deferred。
### 7.7 Wrong vs Correct
```ts
// Wrong: 宽松类型判断和 raw 字段穿透。
if (item.msgType === 10010) return { kind: "order", params: item.originalData.params };
// Correct: MAIN 内完整 tuple 后逐字段投影;任何不合规摘要成为 anomaly。
if (matchesHistoryCard(item, 59, 9)) return normalizeOrder(item);
```
## 8. Reading and change ownership
修改页面桥、Port 或 command 路由时,先阅读 [page-bridge.md](./page-bridge.md)。
@@ -265,7 +327,7 @@ if (direction !== undefined) output.direction = direction;
修改任何出站发送 API、发送结果、旁路观察或 server confirmation 时,先阅读 [send-sop.md](./send-sop.md),并同时检查页面桥与耐久同步边界。
## 8. Global validation
## 9. Global validation
- 端到端确认两条 WebSocket 的连接类型和消息方向互不混用。
- 确认 MAIN 不访问 Bright token、chrome.runtime 或 IndexedDB。
@@ -23,6 +23,7 @@ MIND_TEST_HARNESS_BRIGHT_BASE_URL=http://127.0.0.1:7878
## 3. Contracts
- 包可消费 `@trade-message-center/onetalk-contract`,但不提供给其它 workspace 包消费。任何 `apps/*/package.json` 都不得声明 `@trade-message-center/mind-test-harness` 为任一 dependency 类字段。
- 联调页的 `OneTalkCenterMessage.content` 必须按 shared `content.version=1` exact-shape 验证 `text | image | file | business_card | inquiry | order`。名片只显示已批准的消息时点快照,询盘只显示分类,订单只显示批准的金额、状态和 action 字段;不得读取或展示原始 card 正文、`params``sign`、token 或完整联系人对象。
- 包内不得有 `test/` 目录、`*.test.*` 文件、`test` 脚本、`build` 脚本、TypeScript `outDir` 或提交的 `dist/`。根 `pnpm build` 和生产 Dockerfile 均不得 filter/copy 该包。
- 该包是顺道编写的低优先级工具,不得被根 `dev``typecheck``test``build` 调度;实现完成不要求测试。其启动或运行失败只在独立命令中报告,不能停止、等待或改变 server/extension 主流程。
- 浏览器的 Origin 必须与 server 的 `MIND_PAGE_ORIGIN` 精确相等;默认页面地址为 `http://127.0.0.1:8788`。Cookie 快捷入口只写页面所在 loopback host 的开发 Cookie,生产 Cookie 仍由 Mind 登录设置。
@@ -7,7 +7,7 @@
当前已建立 OneTalk Bright 事实存储 schema,定义位于
[`apps/server/src/database/schema/onetalk.ts`](../../../../apps/server/src/database/schema/onetalk.ts)
- `onetalk_message`:页面事实消息。`channel_account_id + conversation_id + message_id` 复合主键负责幂等;收件和确认发件通过 `direction` 区分。`content` 是唯一内容事实,只承载 shared contract 的 versioned `text | image | file` JSON,不保存包含认证信息的完整 OneTalk envelope。
- `onetalk_message`:页面事实消息。`channel_account_id + conversation_id + message_id` 复合主键负责幂等;收件和确认发件通过 `direction` 区分。`content` 是唯一内容事实,只承载 shared contract 的 versioned `text | image | file | business_card | inquiry | order` JSON,不保存包含认证信息的完整 OneTalk envelope、业务卡 raw 正文或 SDK payload
- `onetalk_conversation`:插件发现的技术会话和共享同步锚点。`channel_account_id + conversation_id` 复合主键,不按 binding 或设备复制;`conversation_kind` 只接受显式 `direct`,未知历史会话保持 `null``sync_phase``sync_result``latest_message_id``history_complete` 表达同步进度及锚点状态,并允许零消息会话。
- `onetalk_contact_profile`Bright 当前联系人资料事实。`channel_account_id + conversation_id` 复合主键,不建立到技术会话表的外键;资料字段允许显式 `null`,只有严格较新的 `observed_at_ms` 才能覆盖整行。
- `onetalk_message_anomaly`:缺字段、协议和同步异常的独立诊断事实。`fingerprint` 仅用于诊断合并;`payload` 必须由写入边界清洗,不能被消息读取、发送或锚点流程消费。
@@ -212,7 +212,7 @@ repository.updateSyncState(context, update, conversationId, conversationKind) ->
- 重复消息返回 `duplicate`,不得覆盖首次事实或再次触发外部事件;允许只更新 `last_observed_at`
- 跨 workspace 收到相同 `channel_account_id + conversation_id + message_id` 时,必须沿用同一条已存在事实:返回 `duplicate`,保留首次写入的 `workspace_id``mind_user_id``binding``device_id`,不得因后续 workspace 改写来源上下文。
- 消息读取按 `channel_account_id + conversation_id` 读取共享事实;workspace 隔离由 Mind 授权 scope 负责,不能把 `workspace_id` 加入消息事实主键或作为第二套消息副本维度。
- `content jsonb` 必须是对象,且 `version=1``kind in (text,image,file)`;应用边界再用 shared exact decoder 验证完整字段。不得保留顶层 `text``content_type`、raw content 或平行投影列。
- `content jsonb` 必须是对象,且 `version=1``kind in (text,image,file,business_card,inquiry,order)`;应用边界再用 shared exact decoder 验证完整字段。不得保留顶层 `text``content_type`、raw content`params``sign`、完整 `contact` 或平行投影列。
- `content` 或文本相同本身不构成重复;只要 `message_id``conversation_id` 不同,就按新的 OneTalk 事实入库。
- `discoverConversation` 只按账号/会话幂等 upsert,不清空已有消息计数、同步结果或锚点。
- anomaly 以 `fingerprint` 唯一合并并递增 `occurrence_count`;payload 必须是领域层清洗后的 JSON。
@@ -243,7 +243,7 @@ repository.updateSyncState(context, update, conversationId, conversationKind) ->
- Domain:断言未知会话 reject、缺字段/未知 content anomaly、fingerprint 合并、metadata-only anomaly、四类同步结果和 latest ID 所属校验。
- WebSocket:断言逐条 ACK、accepted/duplicate/anomaly/rejected 分流、plugin-only 写边界、数据库错误和精确 Mind 发布。
- PostgreSQL:使用显式 `TEST_DATABASE_URL` 执行真实 migration;断言 `0005` 只清空 OneTalk owned facts、旧列被删除、v1 CHECK 生效,随后 text/image/file 能 round-trip;同一复合键只有一行、消息计数为 1、首次 observation type 和来源 workspace 上下文保留,并在跨 workspace 重复上报后仍只有一行;测试账号结束后清理。
- PostgreSQL:使用显式 `TEST_DATABASE_URL` 执行真实 migration;断言 `0005` 只清空 OneTalk owned facts、旧列被删除`0010` 只扩大 v1 kind CHECK,六类 content 都能 round-trip 且 raw card 字段不能入库;同一复合键只有一行、消息计数为 1、首次 observation type 和来源 workspace 上下文保留,并在跨 workspace 重复上报后仍只有一行;测试账号结束后清理。
- Static`db:check`、无 legacy outbox/dispatch 引用、`database commit -> ACK -> publish` 数据流检查。
### 7. Wrong vs Correct
@@ -240,7 +240,7 @@ decodeOneTalkHistoryReadCursor(value) -> cursor | null
- 会话只读取 `conversation_kind = "direct"` 的显式 direct fact。列表/详情返回共享 `CenterConversation``name`/`avatarUrl` 为当前 profile row 的实时值,`participantIds` 固定为空数组,`unreadCount` 固定为 0`latestMessageId` 来自已确认业务锚点,`latestMessageAtMs` 来自 OneTalk 会话列表活动时间,两者允许独立为空。
- 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表直接读取 `onetalk_conversation.last_message_at_ms`,排序与 cursor 都使用绑定账号、query、asOf、`(latestMessageAtMs, conversationId)` 的同一 keyset。实时消息只单调推进会话时间;活跃会话在跨页期间前移时由列表刷新重新出现。profile 必须以同一账号与页面会话复合键另行受限读取,再在内存组合;不得把实时资料当作 SQL JOIN 例外。
- 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 `(sentAtMs, messageId)` keyset;时间窗为 `from <= sentAtMs < to`。内部 `7777` summary listener 必须同时提供两端时间。
- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1``text | image | file` union。read projection 不得解 Base64、`custom.data``contentType`文件名或 URL fallback
- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1``text | image | file | business_card | inquiry | order` union。read projection 不得解 Base64、`custom.data``contentType`卡片原始正文/`params`、文件名或 URL fallback;它只复制 shared contract 已批准的字段
- Mind 联调页由 `apps/mind-test-harness/` 提供,只通过同源 Bright HTTP/WS 访问数据;页面侧的运行时形状校验、文本转义、图片/文件展示和去重边界见 mind-test-harness 规范,不属于 server 路由契约。
- 插件 `plugin.status``sync.status``message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。public HTTP CORS 只允许精确 Origin 和 `Content-Type`internal summary listener 不注册 CORS。
@@ -268,7 +268,7 @@ decodeOneTalkHistoryReadCursor(value) -> cursor | null
### 6. Tests Required
- HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;text/image/file 必须只含 normalized content
- HTTP:public 列表、详情、历史首/后续页、internal summary gate、direct filter、profile 实时内存组合、query、独立 cursor/asOf、半开窗口、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应;六类 content 都必须只含 shared normalized 字段,业务卡不能恢复 Base64 或 raw SDK 字段
- WebSocketMind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
- Mind 联调页行为(offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段)按 mind-test-harness 规范手工验证;server 自动化测试只覆盖 Bright public HTTP/WS 路径与稳定错误。
- PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile 两次受限读取与内存组合、asOf 和真实 migration 后读取仍按账号/会话隔离。
@@ -0,0 +1,12 @@
{"file":".trellis/spec/project/architecture.md","reason":"检查 shared content 是唯一 contract owner,且未新增平行消息/状态来源。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"检查分类和值在 MAIN、bridge、IndexedDB、Bright、server 和 Mind projection 的完整一致性。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"检查 raw WebSocket 不变、durable-first、ACK 和复合消息幂等键未回归。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"检查 raw SDK 字段没有穿过 MAIN-world 页面桥。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"检查新 content kind 通过既有候选/上传流程,不新增补偿或 outbox。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"检查名片快照没有覆盖或重用 contact profile ledger。"}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"执行扩展 focused/full tests、strict typecheck、build 和 format gate。"}
{"file":".trellis/spec/server/backend/index.md","reason":"检查 server decoder、repository、transaction 与 Mind read 保持 fail-closed 和职责边界。"}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"执行 server tests、migration check、strict typecheck、build 和 format gate。"}
{"file":"docs/onetalk-business-card-message-format-observation-2026-09-11.md","reason":"复核名片 tuple、白名单与未覆盖字段没有被过度解释。"}
{"file":"docs/onetalk-inquiry-message-format-observation-2026-09-11.md","reason":"复核询盘仍只分类,未从 DOM 或原始 content 推断业务字段。"}
{"file":"docs/onetalk-order-message-format-observation-2026-09-11.md","reason":"复核订单解码、白名单、sign 排除和未覆盖业务字段。"}
@@ -0,0 +1,102 @@
# OneTalk 结构化消息信息采集分类:技术设计
## 1. 范围与不变量
本任务扩展 `OneTalkMessage.content` 的封闭联合类型,而不新增平行消息表、平行投递帧或第二套消息身份。既有消息事实的幂等键始终是 `channelAccountId + conversationId + messageId`,并继续保留 `messageId``conversationId``senderId``direction``sentAtMs``participantIds``readStatus``messageStatus``unreadCount`
变更只适用于 SDK 历史扁平条目:`history.ts` 已把它们转换为 `sdk_flat_history` 内容来源,并将 `originalData` 仅作为 MAIN-world decoder 的短暂输入。WebSocket raw 分支不扩展业务卡识别;现有 text/image/file decoder、批量诊断与 `unsupported_skipped` 语义保持不变。
## 2. 规范化内容合同
`apps/onetalk-contract/src/content.ts` 是唯一内容类型、exact-shape guard 和 Mind 读取模型的 owner。保留 `content.version = 1`,在同一版本加入三个 kind
```ts
type OneTalkBusinessCardContent = {
version: 1;
kind: "business_card";
contactName: string | null;
companyName: string | null;
countryCode: string | null;
avatarUrl: string | null;
};
type OneTalkInquiryContent = {
version: 1;
kind: "inquiry";
};
type OneTalkOrderAction = {
name: string;
messageKey: string;
payStep: string | null;
};
type OneTalkOrderContent = {
version: 1;
kind: "order";
orderId: string | number | null;
bizCode: string | number | null;
contractId: string | number | null;
id: string | number | null;
tenant: string | number | null;
orderAmount: number;
orderAmountCurrency: string;
paymentAmount: number;
paymentAmountCurrency: string;
statusMessageKey: string;
actions: OneTalkOrderAction[];
};
```
所有内容 kind 继续 exact-shape 验证。名片四项允许 `null`,但非空字符串必须符合共享文本/头像 URL 限制;这是消息时点的 `contact` 投影,和现有 profile ledger 的当前资料不是同一事实。询盘只有类别,不含伪造的空业务字段。订单金额必须是有限非负数,币种、状态键和 action 键必须是非空受限字符串,`payStep` 可以为 `null`;五个关联字段只接受原样字符串、safe integer 或 `null`,不把值 stringify、拼接或从其它字段补偿。动作数和 Base64 输入大小都有固定上限;超限或任一 schema 失败均进入显式 anomaly。
`sign`、原始 `content`、完整 `contact`、完整 `params`、询盘加密标识、令牌和原始 URL 不出 MAIN world。头像 URL 仅以 `avatarUrl` 白名单字段跨层,并沿用现有绝对 HTTP(S) URL 校验。
## 3. MAIN-world 分类与解码
`content-decoder.ts``sdk_flat_history` 分支集中拥有业务卡识别与投影;不在 `history.ts`、bridge、Service Worker 或 server 重复判断。
| kind | 必须同时成立 | MAIN-world 投影 |
| --- | --- | --- |
| `business_card` | `messageType="rec"``type=1``viewType=0``msgType=10010``subType=57``originalData.cardType=1` | `contact.name/companyName/complianceCountryCode/fullPortrait` 的四项快照 |
| `inquiry` | 相同前三项和 `msgType=10010`,并且 `subType=50``cardType=6` | 仅 `{ version: 1, kind: "inquiry" }` |
| `order` | 相同前三项和 `msgType=10010`,并且 `subType=59``cardType=9` | 五个关联字段和受控 Base64-UTF-8-JSON 订单摘要 |
为将 `messageType/type/viewType` 与 decoder 统一,`OneTalkMessageContentSource` 必须仅为历史 SDK 分支携带所需判别元数据;它仍是 MAIN 内部类型,不能进入结果。历史适配器必须丢弃顶层 `content`,只把必要、非敏感的 `contact` 子字段作为独立 decoder 输入。任何联合条件不完整的 `10010` 卡继续遵循既有 ignore/unsupported 路径,不能被分类为文件、订单或询盘。
订单嵌套 `params.params` 解码复用当前严格 Base64 → UTF-8 fatal → JSON 流程,但与媒体 anomaly 分开命名为 card anomaly。Base64、UTF-8、JSON、对象/字段类型、金额、动作数组或上限不合规均保留可计数的异常;合法非纳入类别仍为 `unsupported_skipped`,不是异常。
## 4. 跨层与持久化
数据流不变:
```text
SDK flat history (MAIN only)
-> exact card classifier + whitelist decoder
-> shared OneTalkMessage.content
-> page bridge / runtime.Port
-> IndexedDB candidate (durable first)
-> messages.observed Bright frame
-> onetalk_message.content JSONB
-> existing Mind history / message.created projection
```
`page-bridge`、Service Worker、IndexedDB candidate、Bright creator、server service/repository 和 Mind read projection 只复用扩展后的 shared guard 与 `content` JSON;它们不得读取 SDK 字段或重算分类。消息先 durable write,再上传、服务端提交、ACK 与 publish 的顺序不变。
服务端 `onetalk_message_content_v1_chk` 当前只允许 `text/image/file`,因此需生成一份 Drizzle migration,把约束扩展为六个 kind,并同步 schema。旧行不迁移、不重写;新 JSONB 仍受 contract guard 约束。读取层从同一 `content` JSONB 投影,Mind 不新增字段或读取端点。
## 5. 兼容性、失败与回滚
- 已有 text/image/file exact-shape、media URL 和 raw WebSocket 路径不变。
- 既有未知业务卡仍产生 `unsupported_skipped`;新三类仅在完整历史联合条件下改变为可上传消息。
- 订单不能因解码失败降级为 `inquiry``text`、空 `order` 或正常 `unsupported`;必须生成安全、可观测 card anomaly,且不携带 raw payload。
- 名片内容不得驱动或覆盖 `contact.profile.observed`;两条链路都可存在但分别表达消息快照和当前资料。
- 回滚顺序是先停止产生新 kind,再在部署允许时执行反向 DB check 迁移;现有 JSONB 中的新 kind 会使旧 server 约束/guard 不兼容,因此必须按“contract → server migration → extension”正向顺序发布,回滚前评估已写入的新行。
## 6. 验证矩阵
1. contract:新 kind exact-shape、额外键拒绝、null/URL/ID/金额/action 边界及原有 kind 回归。
2. MAIN decoder:每个完整联合条件成功;相邻 `10010` 的附件与未知卡不误判;订单各解码失败分别可观测;业务卡原始 `content` 不进入结果。
3. bridge + durable sync:新 kind 可以通过既有 decoder、写入 candidate、创建 canonical frame,敏感原始键不存在。
4. serverwire guard、repository copy、JSONB migration/check、读投影和 publish 均接受新 kind;复合幂等键、ACK 和旧 content 回归。
5. quality:定向测试、typecheck、build、全量测试、format check 与 migration check;真实 Chromium 只作为未来历史样本 smokeWebSocket raw 不在本期验收。
@@ -0,0 +1,14 @@
{"file":".trellis/spec/project/architecture.md","reason":"约束跨包 content 合同唯一 owner、MAIN/bridge/SW/server 职责与 durable-first 消息事实边界。"}
{"file":".trellis/spec/project/module-ownership.md","reason":"新增的业务卡 content 类型和 guard 只由 onetalk-contract 定义,消费者通过 canonical export 使用。"}
{"file":".trellis/spec/project/missing-values.md","reason":"订单字段缺失、解码失败和 null 语义必须显式,不能用猜测值或静默默认补偿。"}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"复核 MAIN 历史 SDK 到 bridge、IndexedDB、Bright、server JSONB 与 Mind read 的完整数据流。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"保持 MAIN/ISOLATED/SW 所有权、消息复合幂等键和 durable-write-before-upload 不变量。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"页面桥只接收 normalized message,不能传递 SDK raw content、params 或 contact 对象。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"复核候选持久化、ACK、重启恢复和消息事实的 existing lifecycle。"}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"区分名片消息时点快照与现有联系人当前资料 owner,避免 second source of truth。"}
{"file":".trellis/spec/chrome-extension/frontend/type-safety.md","reason":"要求从 SDK unknown 输入在 MAIN 单点收窄,禁止消费者局部断言或重复 parser。"}
{"file":".trellis/spec/server/backend/index.md","reason":"服务端 OneTalk wire、授权、repository 和数据库边界的实现基线。"}
{"file":".trellis/spec/server/backend/quality-guidelines.md","reason":"规定 server migration、strict typecheck、build 与 test 的验证门禁。"}
{"file":"docs/onetalk-business-card-message-format-observation-2026-09-11.md","reason":"名片历史 SDK 联合条件和四项 contact 快照的运行态观察证据。"}
{"file":"docs/onetalk-inquiry-message-format-observation-2026-09-11.md","reason":"询盘仅分类、没有稳定业务字段和不得使用 DOM/content 推断的观察边界。"}
{"file":"docs/onetalk-order-message-format-observation-2026-09-11.md","reason":"订单历史 SDK 联合条件、Base64 摘要字段和 sign/raw payload 排除边界。"}
@@ -0,0 +1,64 @@
# OneTalk 结构化消息信息采集分类:实施计划
## Scope and coordination
| Scope ID | Execution workspace | Workspace role | Owns | Excludes |
| --- | --- | --- | --- | --- |
| STRUCTURED-MESSAGE | `/Users/ybf/work/trade-message-center` | task-branch | shared content contract、历史 SDK decoder/adapter、bridge guard compatibility、server JSONB migration/read projection、Mind test harness consumer 和对应回归测试 | raw WebSocket decoder/new-message collection、contact profile ledger、发送/授权/路由语义、任何 SDK raw payload 透传 |
用户在 2026-09-11 的“开始!”确认在已审阅的 PRD/设计范围内实施本 scope。所有实施/检查代理使用同一个已声明的 primary checkout;不得把范围外文件或用户既有改动重置、纳入或覆盖。
## 实施顺序
1. **共享 content 合同**
-`apps/onetalk-contract/src/content.ts` 增加名片、询盘、订单 kind、类型、exact-shape guard 和导出。
- 保持 `OneTalkMessage` 通用身份/时间字段及 wire creator 不变;更新 `contract.test.ts` 的有效、无效与 frame decode 矩阵。
- 先运行 contract package 的 typecheck/test,保证任何跨层代码只消费共享合同。
2. **MAIN 历史消息归一化**
-`message-observer/content-decoder.ts` 增加历史业务卡 tuple matcher、名片白名单投影和受限订单摘要解码;扩展内部 source metadata,但不让 raw SDK 字段进入输出。
-`history.ts` 只提取 decoder 所需的 `messageType``type``viewType` 和四个名片 `contact` 候选;继续丢弃业务卡 `content` 与完整原始对象。
- 不修改 `decodeOneTalkRawContent``new.ts` 的 raw 入口或 WebSocket tap;为完整匹配、相邻卡、未知卡、每种订单失败和 raw 不回归补充 extension focused tests。
3. **传递、耐久与服务端接纳**
- 以 shared guard 驱动既有 page bridge、IndexedDB、frame writer、server ingest、repository 和读投影;仅修正因封闭 union 扩展造成的 exhaustive type/test 缺口,禁止增加第二个卡片 parser。
- 更新 `apps/server/src/database/schema/onetalk.ts` 中 JSONB kind check,并用 `pnpm --filter @trade-message-center/server db:generate` 生成 `apps/server/drizzle/` 迁移及 meta 快照。迁移只扩大约束,不改写历史消息。
- 补 server wire/领域/repository/reading 回归测试,断言完整白名单 round-trip,且 `sign`、原始 `content`、完整 `contact/params` 不存在。
4. **全链路审查与质量门禁**
- 执行定向 contract、extension 和 server 测试;检查 contract ↔ page ↔ frame ↔ IndexedDB ↔ server JSONB ↔ Mind projection 的 kind 一致性。
- 执行 `pnpm format:check``pnpm typecheck``pnpm build``pnpm test`;对 server DB 迁移执行 `pnpm --filter @trade-message-center/server db:check`
- 用 GitNexus `detect_changes` 核对受影响 symbols/flows;检查 diff 没有 raw WebSocket 或 contact-profile owner 的意外改动。
## 高风险点与检查点
| 检查点 | 风险 | 必须保持的结果 |
| --- | --- | --- |
| content union | exact-shape guard 漏分支,导致桥或 server 拒绝合法卡 | contract 是唯一类型/validator owner;所有消费者复用它 |
| 历史 tuple | `msgType=10010` 误分类附件或未知卡 | 六元联合条件完整,附件仍是 file,未知卡仍 unsupported |
| order Base64 | 空值或解码失败变成空订单 | 每种失败进入可观测 card anomalyraw 不越界 |
| 名片快照 | 覆盖当前 contact profile 或复制完整 `contact` | 仅四个字段进入 message contentprofile ledger 无修改 |
| server migration | 新 kind 被 JSONB check 拒绝或旧行受影响 | 先扩 contract,再迁移 check;只扩大允许集合 |
| 运行态来源 | 顺手修改 raw WebSocket 形成未验证采集 | raw decoder/new message 行为和测试均不变 |
## 回滚点
- 合同或 MAIN decoder 不通过 focused test:停止在实施步骤 1/2,不触及持久化 schema。
- server migration 生成或 `db:check` 失败:停止在步骤 3,修复 schema/migration 一致性后再继续;不手工编辑 snapshot。
- end-to-end guard/round-trip 失败:回到产生 normalized content 的唯一 MAIN decoder,禁止在 bridge、Service Worker 或 server 添加补偿 parser。
- 发布后如需回滚:先阻止 extension 产生新 kind;在存在新 JSONB kind 时不得直接部署只认识旧三种 kind 的 server。
## 验证命令
```bash
pnpm --filter @trade-message-center/onetalk-contract test
pnpm --filter @trade-message-center/chrome-extension test -- onetalk-media-content-decoder.test.js
pnpm --filter @trade-message-center/server test -- onetalk-domain.test.ts onetalk-read-domain.test.ts onetalk-websocket.test.ts
pnpm --filter @trade-message-center/server db:check
pnpm format:check
pnpm typecheck
pnpm build
pnpm test
```
真实 Chromium 验收仅在用户提供已登录 OneTalk 页面后,针对历史 SDK 扁平消息做只读 smoke;不发送消息、不切换会话、不记录原始数据。本期不进行 WebSocket raw 卡片 smoke。
@@ -0,0 +1,49 @@
# OneTalk 结构化消息信息采集分类
## Goal
在 OneTalk 已有文本、图片和文件消息采集之外,增加名片、询盘和订单三种已观察到的业务卡片类别;每个类别只能产生经批准的白名单化信息,不能让原始 SDK 对象、正文、签名或未批准字段跨越 MAIN-world 边界。
## Confirmed Facts
- 2026-09-11 的 Chromium CDP 只读 SDK 观察确认三种历史消息必须用联合条件识别:名片 `messageType=rec/type=1/viewType=0/msgType=10010/subType=57/cardType=1`;询盘为 `subType=50/cardType=6`;订单为 `subType=59/cardType=9``msgType=10010` 本身不是充分条件,附件也属于该卡片族。
- 当前 MAIN-world 内容归一化只支持 text、image 和 `cardType=12` 的 file;其余业务卡片会返回 `unsupported_skipped`,随后在批处理中只计数而不会跨层传递。
- 名片样本的 `contact` 含显示名、公司、国家/地区代码及头像 URL 候选,但它们是会话联系人资料而非名片内容;未验证独立邮箱字段或可复用的正文格式。
- 询盘样本的 `params` 只观察到加密的询盘/贸易关联标识和若干来源字段;商品标题、数量、需求和图片仅存在于当前可见 DOM,不能成为稳定消息合同。
- 订单样本有可解码的 Base64-UTF-8-JSON 摘要,包含订单/实付金额与币种、状态键、动作和部分付款阶段;只有 D3 列出的订单关联字段可跨边界,签名、参与方标识和完整 payload 仍不可跨边界。
## Requirements
- R1:为名片、询盘和订单定义互斥、可验证的信息采集类别;类别判断必须要求完整的 SDK 联合判别条件,不能通过 `msgType=10010` 或 UI 文本猜测。
- R2:继续在 MAIN world 内完成卡片识别、受控解码与白名单投影;页面桥、Service Worker、持久化、Bright 与 Mind 之间只能传递最终类别和 D2/D3 批准字段,不能传递原始 `message``content``originalData.params``contact` 整体、`sign`、加密标识、令牌或序列化原始对象。
- R3:未知或不匹配 schema 的业务卡片必须保持显式、可观测的 `unsupported` / anomaly 结果;不得伪装为文本、文件、空订单或空询盘。
- R4:保留已有文本、图片、文件和 `cardType=12` 附件识别语义;新类别不得扩大 D2 以外的联系人资料、媒体 URL 或业务详情采集范围。
- R5:为每一种纳入 MVP 的类别覆盖:完整匹配、相同 `msgType` 的相邻类别不误判、异常解码或 schema、白名单边界及跨层合同。
## Product Direction
- D1:本期目标是尽可能采集对业务有用的原始消息信息;用户要求先共同确认逐类采集边界。
- D2:名片卡片纳入 `contact.name``contact.companyName``contact.complianceCountryCode``contact.fullPortrait`。它们必须标注为消息观察时取得的会话联系人资料快照,不能宣称是名片正文的权威字段;缺值如实保留为 `null`,不猜测或回填。
- D3:订单卡片纳入订单金额及币种、实付金额及币种、`statusMessageKey`、动作列表的名称/状态键和可用 `payStep`,以及 `orderId``contractId``bizCode``id``tenant` 订单关联字段。嵌套 Base64 摘要仍只在 MAIN world 解码;跨层只传其白名单投影。
- D4:询盘本期只归类,暂不纳入专有业务字段;既有 `unsupported` / anomaly 路径须保持可观测,不能伪造为空询盘内容。
- D5:所有已接纳类别继续使用现有通用消息事实:`messageId``conversationId``senderId``direction``sentAtMs``participantIds``readStatus``messageStatus``unreadCount``channelAccountId` 是插件帧/耐久键的账号作用域,而非消息内容字段。分类扩展不得删改这些字段的身份、时间和去重语义。
- D6(约束):这不授权透传原始 SDK 对象或序列化原始 payload。任何跨 MAIN-world 的字段必须逐字段白名单投影;`sign`、令牌、完整 `contact`、完整 `params` 和原始正文仍默认禁止。名片头像 URL 是 D2 的明确例外,沿用现有头像 URL 校验与 `null` 语义。
- D7:业务卡原始消息的 `content` 不在本期采集范围:不得解析、持久化、跨层传递或据此推断结构化字段。此限制只作用于本期新业务卡;既有纯文本消息的 `content.kind = "text"` 归一化和传递语义保持不变。
- D8:名片、询盘和订单只从已观察到完整联合判别条件的 SDK 历史扁平消息采集。WebSocket raw 路径本期不改动,继续使用既有 text/image/file 归一化和 `unsupported` 结果;不得以 `custom.type=10010``cardType` 或 UI 文本猜测新类别。
- D9:名片 `content` 保存的是消息时点的资料快照;既有 `contact.profile.observed` 仍是会话当前资料的唯一 owner。两者语义不同,不能相互覆盖、回填或把消息快照写进 profile ledger。
## Out of Scope
- 从卡片 DOM、普通字符串 `content` 或页面按钮反推商品标题、数量、需求、详情链接、地址、邮箱或人类可读状态。
- 采集或持久化加密 ID、`sign`、聊天令牌、完整联系人对象、完整订单/询盘 payload、原始 URL 或正文。
- 改动 WebSocket raw 卡片的识别、采集或实时投递;此路径需要独立运行态观察后再设计。
- 声称三个单样本观察构成 OneTalk 的全局消息类型枚举或上游 API 合同。
- 改动既有 buyer-facts 的 DOM 采集语义,除非后续确认需要且单独设计兼容边界。
## Acceptance Criteria
- [ ] 三类业务卡各有精确、测试覆盖的历史 SDK 联合判别条件,且不会把附件或其它 `msgType=10010` 卡片误归类;WebSocket raw 行为不变。
- [ ] 名片资料快照、订单白名单字段和仅分类的询盘各有明确的跨层合同与来源语义;敏感字段与未验证字段不能通过桥、持久化或服务端输入验证。
- [ ] Base64 订单摘要的大小、编码、JSON 和 schema 失败均得到显式可观测结果,绝不静默变为“无订单”。
- [ ] 已有 text/image/file 与 `unsupported` 行为回归通过;不需要新类别的信息仍保持原样。
- [ ] 任务规划在实现前明确 MVP 字段范围、兼容策略、测试边界及未覆盖的运行态变体。
@@ -0,0 +1,32 @@
{
"id": "onetalk-structured-message-information-collection-classification",
"name": "onetalk-structured-message-information-collection-classification",
"title": "OneTalk 结构化消息信息采集分类",
"description": "基于 2026-09-11 名片、询盘和订单格式观察,规划受控的信息采集类别。",
"status": "in_progress",
"dev_type": null,
"scope": "cross-package",
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-11",
"completedAt": null,
"branch": "09-11-onetalk-structured-message-information-collection-classification",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {
"execution_workspace": "/Users/ybf/work/trade-message-center",
"workspace_role": "task-branch",
"scope_lane": "STRUCTURED-MESSAGE",
"owns": "shared contract, history classifier, bridge compatibility, server JSONB, Mind harness",
"excludes": "raw WebSocket, profile ledger, send, authorization, routing, raw SDK payload"
}
}
@@ -0,0 +1,43 @@
version: 1
task_path: /Users/ybf/work/trade-message-center/.trellis/tasks/09-11-onetalk-structured-message-information-collection-classification
coordination_checkout: /Users/ybf/work/trade-message-center
accepted_base: 2dfd1a4cc511fdf4f75e5328c377b45ec244636e
git_common_dir: /Users/ybf/work/trade-message-center/.git
scope_lanes:
STRUCTURED-MESSAGE:
path: /Users/ybf/work/trade-message-center
role: task-branch
owns:
- apps/onetalk-contract/src/content.ts
- apps/onetalk-contract/src/guards.ts
- apps/onetalk-contract/src/contact-profiles.ts
- apps/onetalk-contract/src/index.ts
- apps/onetalk-contract/test/contract.test.ts
- apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts
- apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts
- apps/chrome-extension/src/onetalk/page-bridge/model.ts
- apps/chrome-extension/test/onetalk-media-content-decoder.test.js
- apps/chrome-extension/test/onetalk-page-bridge.test.js
- apps/chrome-extension/test/onetalk-service-worker-runtime.test.js
- apps/chrome-extension/test/onetalk-sync-engine.test.js
- apps/chrome-extension/test/onetalk-websocket-tap.test.js
- apps/server/src/database/schema/onetalk.ts
- apps/server/drizzle/0010_wooden_naoko.sql
- apps/server/drizzle/meta/0010_snapshot.json
- apps/server/drizzle/meta/_journal.json
- apps/server/test/onetalk-domain.test.ts
- apps/server/test/onetalk-read-domain.test.ts
- apps/mind-test-harness/src/harness/validators.ts
- apps/mind-test-harness/src/harness/messages.ts
excludes:
- apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts
- apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts
- apps/chrome-extension/src/onetalk/service-worker/contact-profile-coordinator.ts
- apps/chrome-extension/src/onetalk/service-worker/flows/send-command-flow.ts
- apps/server/src/mind-authorization.ts
- apps/server/src/websocket/handler.ts
- apps/server/src/websocket/registry.ts
- package.json
- pnpm-lock.yaml
depends_on: []
lifecycle: shared checkout retained through final review
@@ -16,10 +16,15 @@ export const ONE_TALK_MEDIA_ANOMALY_CODES = [
"media_payload_too_large",
"media_invalid_schema",
"media_invalid_url",
"card_invalid_base64",
"card_invalid_utf8",
"card_invalid_json",
"card_payload_too_large",
"card_invalid_schema",
] as const;
export type OneTalkMediaAnomalyCode = (typeof ONE_TALK_MEDIA_ANOMALY_CODES)[number];
export type OneTalkMediaKind = "image" | "file";
export type OneTalkMediaKind = "image" | "file" | "card";
export type OneTalkRawContentDecodeResult =
| { status: "decoded"; content: OneTalkMessageContent }
@@ -31,12 +36,27 @@ export type OneTalkMessageContentSource =
| { source: "raw"; value: unknown }
| {
source: "sdk_flat_history";
messageType: unknown;
type: unknown;
viewType: unknown;
msgType: unknown;
subType: unknown;
originalData: unknown;
cardType: unknown;
contactName: unknown;
companyName: unknown;
countryCode: unknown;
avatarUrl: unknown;
orderId: unknown;
bizCode: unknown;
contractId: unknown;
id: unknown;
tenant: unknown;
orderSummary: unknown;
};
const MAX_ENCODED_MEDIA_PAYLOAD_BYTES = 512 * 1024;
const MAX_ENCODED_CARD_PAYLOAD_BYTES = 512 * 1024;
const MAX_MEDIA_URL_LENGTH = 8 * 1024;
const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm";
const THUMBNAIL_URL_PATH = "/file/videoThumb.htm";
@@ -108,6 +128,40 @@ const decodeMediaPayload = (
}
};
const decodeOrderSummary = (
value: unknown,
):
| { ok: true; summary: Record<string, unknown> }
| { ok: false; result: OneTalkRawContentDecodeResult } => {
if (typeof value !== "string" || !isStrictBase64(value)) {
return { ok: false, result: anomaly("card_invalid_base64", "card") };
}
if (value.length > MAX_ENCODED_CARD_PAYLOAD_BYTES) {
return { ok: false, result: anomaly("card_payload_too_large", "card") };
}
let decoded: Uint8Array;
try {
const binary = atob(value);
decoded = Uint8Array.from(binary, (character) => character.charCodeAt(0));
} catch {
return { ok: false, result: anomaly("card_invalid_base64", "card") };
}
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(decoded);
} catch {
return { ok: false, result: anomaly("card_invalid_utf8", "card") };
}
try {
const parsed: unknown = JSON.parse(text);
return isObjectRecord(parsed)
? { ok: true, summary: parsed }
: { ok: false, result: anomaly("card_invalid_schema", "card") };
} catch {
return { ok: false, result: anomaly("card_invalid_json", "card") };
}
};
const isAllowedUrl = (
value: string,
expectedPath: string,
@@ -255,9 +309,99 @@ const normalizeFile = (payload: Record<string, unknown>): OneTalkRawContentDecod
: anomaly("media_invalid_schema", "file");
};
const nullableCardText = (value: unknown): string | null | unknown => {
return value === undefined || value === null || value === "" ? null : value;
};
const nullableOrderIdentifier = (value: unknown): string | number | null | unknown => {
return value === undefined || value === null ? null : value;
};
const matchesBusinessCard = (
source: Extract<OneTalkMessageContentSource, { source: "sdk_flat_history" }>,
subType: number,
cardType: number,
): boolean => {
return (
source.messageType === "rec" &&
source.type === 1 &&
source.viewType === 0 &&
source.msgType === 10010 &&
source.subType === subType &&
source.cardType === cardType
);
};
const normalizeBusinessCard = (
source: Extract<OneTalkMessageContentSource, { source: "sdk_flat_history" }>,
): OneTalkRawContentDecodeResult => {
const normalized = {
version: ONETALK_CONTENT_VERSION,
kind: "business_card" as const,
contactName: nullableCardText(source.contactName),
companyName: nullableCardText(source.companyName),
countryCode: nullableCardText(source.countryCode),
avatarUrl: nullableCardText(source.avatarUrl),
};
return isOneTalkMessageContent(normalized)
? { status: "decoded", content: normalized }
: anomaly("card_invalid_schema", "card");
};
const orderActions = (
value: unknown,
): ({ name: unknown; messageKey: unknown; payStep: unknown } | null)[] | null => {
if (!Array.isArray(value)) return null;
return value.map((action) => {
if (!isObjectRecord(action) || !isObjectRecord(action.properties)) return null;
return {
name: action.name,
messageKey: action.messageKey,
payStep: action.properties.payStep ?? null,
};
});
};
const normalizeOrder = (
source: Extract<OneTalkMessageContentSource, { source: "sdk_flat_history" }>,
): OneTalkRawContentDecodeResult => {
const decoded = decodeOrderSummary(source.orderSummary);
if (!decoded.ok) return decoded.result;
const actions = orderActions(decoded.summary.actionList);
if (actions === null || actions.some((action) => action === null)) {
return anomaly("card_invalid_schema", "card");
}
const normalized = {
version: ONETALK_CONTENT_VERSION,
kind: "order" as const,
orderId: nullableOrderIdentifier(source.orderId),
bizCode: nullableOrderIdentifier(source.bizCode),
contractId: nullableOrderIdentifier(source.contractId),
id: nullableOrderIdentifier(source.id),
tenant: nullableOrderIdentifier(source.tenant),
orderAmount: decoded.summary.orderAmount,
orderAmountCurrency: decoded.summary.orderAmountCurrency,
paymentAmount: decoded.summary.paymentAmount,
paymentAmountCurrency: decoded.summary.paymentAmountCurrency,
statusMessageKey: decoded.summary.statusMessageKey,
actions,
};
return isOneTalkMessageContent(normalized)
? { status: "decoded", content: normalized }
: anomaly("card_invalid_schema", "card");
};
const decodeOneTalkFlatHistoryContent = (
source: Extract<OneTalkMessageContentSource, { source: "sdk_flat_history" }>,
): OneTalkRawContentDecodeResult => {
if (matchesBusinessCard(source, 57, 1)) return normalizeBusinessCard(source);
if (matchesBusinessCard(source, 50, 6)) {
return {
status: "decoded",
content: { version: ONETALK_CONTENT_VERSION, kind: "inquiry" },
};
}
if (matchesBusinessCard(source, 59, 9)) return normalizeOrder(source);
if (source.msgType === 102 && source.subType === 60) {
return isObjectRecord(source.originalData)
? normalizeImage(source.originalData)
@@ -49,6 +49,7 @@ const flatHistoryMessage = (
participantIds: string[],
): Record<string, unknown> => {
const {
contact: _contact,
content: _displayContent,
conversationCode,
messageId,
@@ -68,6 +69,32 @@ const flatHistoryMessage = (
};
};
const historyCardSource = (item: Record<string, unknown>): OneTalkMessageContentSource => {
const originalData = isObjectRecord(item.originalData) ? item.originalData : undefined;
const contact = isObjectRecord(item.contact) ? item.contact : undefined;
const orderParams = isObjectRecord(originalData?.params) ? originalData.params : undefined;
return {
source: "sdk_flat_history",
messageType: item.messageType,
type: item.type,
viewType: item.viewType,
msgType: item.msgType,
subType: item.subType,
originalData: item.originalData,
cardType: originalData?.cardType,
contactName: contact?.name,
companyName: contact?.companyName,
countryCode: contact?.complianceCountryCode,
avatarUrl: contact?.fullPortrait,
orderId: orderParams?.orderId,
bizCode: orderParams?.bizCode,
contractId: orderParams?.contractId,
id: orderParams?.id,
tenant: orderParams?.tenant,
orderSummary: orderParams?.params,
};
};
const historyMessageInput = (item: unknown): HistoryMessageInput | null => {
if (!isObjectRecord(item)) return null;
const wrapperMessage = isObjectRecord(item.message) ? item.message : undefined;
@@ -87,12 +114,7 @@ const historyMessageInput = (item: unknown): HistoryMessageInput | null => {
readStatus: item.unread,
messageStatus: item.status,
shape: "flat",
contentSource: {
source: "sdk_flat_history",
msgType: item.msgType,
subType: item.subType,
originalData: item.originalData,
},
contentSource: historyCardSource(item),
};
};
@@ -382,8 +382,13 @@ const decodeDiagnostics = (value: unknown): OneTalkObservationDiagnostics | null
code !== "media_invalid_json" &&
code !== "media_payload_too_large" &&
code !== "media_invalid_schema" &&
code !== "media_invalid_url") ||
(mediaKind !== "image" && mediaKind !== "file") ||
code !== "media_invalid_url" &&
code !== "card_invalid_base64" &&
code !== "card_invalid_utf8" &&
code !== "card_invalid_json" &&
code !== "card_payload_too_large" &&
code !== "card_invalid_schema") ||
(mediaKind !== "image" && mediaKind !== "file" && mediaKind !== "card") ||
typeof count !== "number" ||
!Number.isSafeInteger(count) ||
count < 1
@@ -52,6 +52,51 @@ const rawFile = (payload) => ({
custom: { type: 10010, data: base64Json(payload) },
});
const flatBusinessCard = (overrides = {}) => ({
source: "sdk_flat_history",
messageType: "rec",
type: 1,
viewType: 0,
msgType: 10010,
subType: 57,
originalData: { cardType: 1 },
cardType: 1,
contactName: "Buyer Name",
companyName: "Buyer Company",
countryCode: "CN",
avatarUrl: "https://cdn.example.test/avatar/buyer.jpg",
orderId: undefined,
bizCode: undefined,
contractId: undefined,
id: undefined,
tenant: undefined,
orderSummary: undefined,
...overrides,
});
const flatOrder = (overrides = {}) => {
const summary = {
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actionList: [{ name: "pay", messageKey: "order.pay", properties: { payStep: "deposit" } }],
};
return flatBusinessCard({
subType: 59,
originalData: { cardType: 9 },
cardType: 9,
orderId: "order-1",
bizCode: 42,
contractId: "contract-1",
id: "id-1",
tenant: "tenant-1",
orderSummary: base64Json(summary),
...overrides,
});
};
test("normalizes real-shape JPEG, ZIP, PDF, and generic cardType=12 files", () => {
assert.deepEqual(decodeOneTalkRawContent(rawImage()), {
status: "decoded",
@@ -153,6 +198,156 @@ test("normalizes exact SDK flat history media through the shared media contract"
);
});
test("classifies only complete flat-history business-card tuples and whitelists the snapshot", () => {
const result = decodeOneTalkMessageContent(flatBusinessCard());
assert.deepEqual(result, {
status: "decoded",
content: {
version: 1,
kind: "business_card",
contactName: "Buyer Name",
companyName: "Buyer Company",
countryCode: "CN",
avatarUrl: "https://cdn.example.test/avatar/buyer.jpg",
},
});
assert.equal(JSON.stringify(result).includes("originalData"), false);
assert.equal(JSON.stringify(result).includes("sign"), false);
for (const overrides of [
{ messageType: "send" },
{ type: 2 },
{ viewType: 1 },
{ msgType: 10009 },
{ subType: 61, cardType: 12, originalData: { cardType: 12 } },
{ subType: 50, cardType: 6, originalData: { cardType: 6 } },
]) {
const adjacent = decodeOneTalkMessageContent(flatBusinessCard(overrides));
assert.notEqual(
adjacent.status === "decoded" ? adjacent.content.kind : null,
"business_card",
);
}
});
test("classifies inquiry without projecting unapproved card fields", () => {
const result = decodeOneTalkMessageContent(
flatBusinessCard({ subType: 50, cardType: 6, originalData: { cardType: 6 } }),
);
assert.deepEqual(result, { status: "decoded", content: { version: 1, kind: "inquiry" } });
assert.equal(JSON.stringify(result).includes("contactName"), false);
});
test("decodes an exact flat-history order into only approved fields", () => {
const result = decodeOneTalkMessageContent(flatOrder());
assert.deepEqual(result, {
status: "decoded",
content: {
version: 1,
kind: "order",
orderId: "order-1",
bizCode: 42,
contractId: "contract-1",
id: "id-1",
tenant: "tenant-1",
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actions: [{ name: "pay", messageKey: "order.pay", payStep: "deposit" }],
},
});
assert.equal(JSON.stringify(result).includes("orderSummary"), false);
});
test("keeps every invalid order summary observable without exposing its raw payload", () => {
const cases = [
["not-base64", "card_invalid_base64"],
[Buffer.from([0xc3, 0x28]).toString("base64"), "card_invalid_utf8"],
[Buffer.from("{", "utf8").toString("base64"), "card_invalid_json"],
["AAAA".repeat(131_073), "card_payload_too_large"],
[base64Json({ actionList: [] }), "card_invalid_schema"],
[
base64Json({
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actionList: [{ name: "pay", messageKey: "order.pay", properties: null }],
}),
"card_invalid_schema",
],
[
base64Json({
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actionList: [{ name: "pay", messageKey: "order.pay", properties: [] }],
}),
"card_invalid_schema",
],
[
base64Json({
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actionList: [{ name: "pay", messageKey: "order.pay", properties: "invalid" }],
}),
"card_invalid_schema",
],
];
for (const [orderSummary, code] of cases) {
const result = decodeOneTalkMessageContent(flatOrder({ orderSummary }));
assert.deepEqual(result, { status: "anomaly", code, mediaKind: "card" });
assert.equal(JSON.stringify(result).includes(String(orderSummary)), false);
}
});
test("keeps an absent payStep while rejecting a missing action properties object", () => {
const withoutPayStep = {
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actionList: [{ name: "pay", messageKey: "order.pay", properties: {} }],
};
assert.deepEqual(
decodeOneTalkMessageContent(flatOrder({ orderSummary: base64Json(withoutPayStep) })),
{
status: "decoded",
content: {
version: 1,
kind: "order",
orderId: "order-1",
bizCode: 42,
contractId: "contract-1",
id: "id-1",
tenant: "tenant-1",
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actions: [{ name: "pay", messageKey: "order.pay", payStep: null }],
},
},
);
const missingProperties = {
...withoutPayStep,
actionList: [{ name: "pay", messageKey: "order.pay" }],
};
assert.deepEqual(
decodeOneTalkMessageContent(flatOrder({ orderSummary: base64Json(missingProperties) })),
{ status: "anomaly", code: "card_invalid_schema", mediaKind: "card" },
);
});
test("skips a legal non-file business card and aggregates every safe media anomaly", () => {
assert.deepEqual(decodeOneTalkRawContent(rawFile({ cardType: 2000, params: {} })), {
status: "unsupported_skipped",
@@ -189,6 +189,20 @@ test("decodes one versioned JSON envelope and rejects malformed shapes", () => {
decodeOneTalkPageMessage(invalidObservationDiagnostic),
invalidObservationDiagnostic,
);
for (const code of [
"card_invalid_base64",
"card_invalid_utf8",
"card_invalid_json",
"card_payload_too_large",
"card_invalid_schema",
]) {
const cardDiagnostic = createOneTalkPageObservedMessage([], undefined, {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [{ code, mediaKind: "card", count: 1 }],
});
assert.deepEqual(decodeOneTalkPageMessage(cardDiagnostic), cardDiagnostic);
}
assert.equal(
decodeOneTalkPageMessage({
...observed,
@@ -165,6 +165,39 @@ test("registers page identity and dispatches each valid observation once", () =>
assert.deepEqual(senders, [pageSender(3), pageSender(3)]);
});
test("forwards every structured-card diagnostic through the Service Worker boundary", async () => {
const observations = [];
const runtime = createOneTalkServiceWorkerRuntime({
onPageMessage: (message) => observations.push(message),
});
const port = new FakePort(pageSender(4));
connectPage(runtime, port, "account-1", "conversation-1");
const codes = [
"card_invalid_base64",
"card_invalid_utf8",
"card_invalid_json",
"card_payload_too_large",
"card_invalid_schema",
];
for (const code of codes) {
port.dispatchMessage(
createOneTalkPageObservedMessage([], undefined, {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [{ code, mediaKind: "card", count: 1 }],
}),
);
}
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
observations.map((message) => message.diagnostics.anomalies[0].code),
codes,
);
assert.equal(JSON.stringify(observations).includes("raw"), false);
});
test("routes an account-level command only to the unique page without conversation identity", async () => {
const runtime = createOneTalkServiceWorkerRuntime({ onPageMessage: () => undefined });
const port = new FakePort(pageSender(31));
@@ -647,7 +647,10 @@ test("records aggregated media diagnostics without creating a candidate or ancho
diagnostics: {
unsupportedSkippedCount: 1,
invalidObservationCount: 3,
anomalies: [{ code: "media_invalid_json", mediaKind: "file", count: 2 }],
anomalies: [
{ code: "media_invalid_json", mediaKind: "file", count: 2 },
{ code: "card_invalid_schema", mediaKind: "card", count: 1 },
],
},
},
scope.channelAccountId,
@@ -660,6 +663,7 @@ test("records aggregated media diagnostics without creating a candidate or ancho
["unsupported_skipped", 1, ["card"]],
["invalid_observation", 3, ["observation"]],
["media_invalid_json", 2, ["file"]],
["card_invalid_schema", 1, ["card"]],
],
);
assert.equal(store.candidates.size, 0);
@@ -98,6 +98,7 @@ const flatHistoryMediaItem = (messageId, msgType, subType, originalData) => ({
msgType,
subType,
messageType: "rec",
viewType: 0,
status: 1,
unread: 2,
sender: { targetId: "2208314000798" },
@@ -416,6 +417,68 @@ test("adapts exact SDK flat history images and attachments through the sole hist
});
});
test("adapts only approved structured card projections through the sole history entry", () => {
const { pageWindow } = testPageWindow();
const orderSummary = Buffer.from(
JSON.stringify({
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actionList: [
{ name: "pay", messageKey: "order.pay", properties: { payStep: "deposit" } },
],
}),
"utf8",
).toString("base64");
const batch = createParsedMessageBatch(
parseHistoryMessages(
[
{
...flatHistoryMediaItem("history-business-card", 10010, 57, {
cardType: 1,
params: { sign: "secret-sign" },
}),
contact: {
name: "Buyer Name",
companyName: "Buyer Company",
complianceCountryCode: "CN",
fullPortrait: "https://cdn.example.test/avatar/buyer.jpg",
accountIdEncrypt: "secret-account",
},
},
flatHistoryMediaItem("history-inquiry", 10010, 50, { cardType: 6 }),
flatHistoryMediaItem("history-order", 10010, 59, {
cardType: 9,
params: {
orderId: "order-1",
bizCode: 42,
contractId: "contract-1",
id: "id-1",
tenant: "tenant-1",
sign: "secret-sign",
params: orderSummary,
},
}),
],
pageWindow,
),
);
assert.deepEqual(
batch.messages.map((message) => [message.messageId, message.content.kind]),
[
["history-business-card", "business_card"],
["history-inquiry", "inquiry"],
["history-order", "order"],
],
);
assert.equal(JSON.stringify(batch).includes("secret-sign"), false);
assert.equal(JSON.stringify(batch).includes("secret-account"), false);
assert.equal(JSON.stringify(batch).includes(orderSummary), false);
});
test("isolates invalid flat history items without dropping valid siblings", () => {
const { pageWindow } = testPageWindow();
const valid = flatHistoryItem(rawTextMessage("valid-flat"));
@@ -25,6 +25,25 @@ export const harnessMessagesScript = String.raw` const messageKey = (
: '<p class="media-error">图片未提供预览地址。</p>';
return '<div class="message-content"><p>图片</p><p class="message-meta">' + metadata + '</p>' + preview + '</div>';
}
if (content.kind === 'business_card') {
const fields = [content.contactName, content.companyName, content.countryCode]
.filter((value) => value !== null)
.map(escapeHtml)
.join(' · ');
const avatar = content.avatarUrl
? '<img class="message-image" src="' + escapeHtml(content.avatarUrl) + '" alt="联系人头像" onerror="this.hidden=true">'
: '';
return '<div class="message-content"><p>名片资料快照</p><p class="message-meta">' + (fields || '未提供资料字段') + '</p>' + avatar + '</div>';
}
if (content.kind === 'inquiry') {
return '<div class="message-content"><p>询盘卡片</p><p class="message-meta">本期只提供分类,不展示未批准的业务字段。</p></div>';
}
if (content.kind === 'order') {
const amounts = escapeHtml(content.orderAmountCurrency + ' ' + content.orderAmount + ' · ' + content.paymentAmountCurrency + ' ' + content.paymentAmount);
const actions = content.actions.map((action) => escapeHtml(action.name + ' · ' + action.messageKey + (action.payStep === null ? '' : ' · ' + action.payStep))).join('<br>');
return '<div class="message-content"><p>订单</p><p class="message-meta">' + amounts + ' · ' + escapeHtml(content.statusMessageKey) + '</p>' + (actions ? '<p class="message-meta">' + actions + '</p>' : '') + '</div>';
}
if (content.kind !== 'file') return '<div class="message-content"><p>不支持的规范化消息内容。</p></div>';
const links = [
renderLink(content.previewUrl, '预览'),
renderLink(content.thumbnailUrl, '缩略图'),
@@ -13,6 +13,25 @@ export const harnessValidatorsScript = String.raw` const isRecord = (
const isNullableString = (value) => value === null || typeof value === 'string';
const isNonBlankString = (value) => typeof value === 'string' && value.trim() && !/[\u0000-\u001f\u007f]/u.test(value);
const isNonBlankText = (value) => typeof value === 'string' && value.trim() && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value);
const isNonBlankStringBounded = (value, maximumLength) => isNonBlankString(value) && value.length <= maximumLength;
const isNonBlankTextBounded = (value, maximumLength) => isNonBlankText(value) && value.length <= maximumLength;
const isNullableText = (value) => value === null || isNonBlankTextBounded(value, 64 * 1024);
const isAvatarUrl = (value) => {
if (typeof value !== 'string' || !value || value.trim() !== value || /\s/u.test(value)) return false;
try {
const url = new URL(value);
return (url.protocol === 'http:' || url.protocol === 'https:') && url.hostname.length > 0;
} catch {
return false;
}
};
const isOrderIdentifier = (value) => value === null || isNonBlankStringBounded(value, 512) || Number.isSafeInteger(value);
const isOrderAmount = (value) => typeof value === 'number' && Number.isFinite(value) && value >= 0;
const isOrderAction = (value) => isRecord(value)
&& hasExactKeys(value, ['messageKey', 'name', 'payStep'])
&& isNonBlankTextBounded(value.name, 64 * 1024)
&& isNonBlankTextBounded(value.messageKey, 64 * 1024)
&& isNullableText(value.payStep);
const isNonNegativeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
const isNormalizedContent = (value) => {
@@ -27,6 +46,28 @@ export const harnessValidatorsScript = String.raw` const isRecord = (
&& isNonNegativeInteger(value.sizeBytes) && isNullableString(value.md5) && isNullableString(value.previewUrl)
&& isNullableString(value.thumbnailUrl) && isNullableString(value.downloadUrl)
&& (value.downloadState === 'available' || value.downloadState === 'not_provided') && value.urlScope === 'onetalk_session';
if (value.kind === 'business_card') return hasExactKeys(value, ['avatarUrl', 'companyName', 'contactName', 'countryCode', 'kind', 'version'])
&& value.version === contentVersion
&& isNullableText(value.contactName)
&& isNullableText(value.companyName)
&& isNullableText(value.countryCode)
&& (value.avatarUrl === null || isAvatarUrl(value.avatarUrl));
if (value.kind === 'inquiry') return hasExactKeys(value, ['kind', 'version']) && value.version === contentVersion;
if (value.kind === 'order') return hasExactKeys(value, ['actions', 'bizCode', 'contractId', 'id', 'kind', 'orderAmount', 'orderAmountCurrency', 'orderId', 'paymentAmount', 'paymentAmountCurrency', 'statusMessageKey', 'tenant', 'version'])
&& value.version === contentVersion
&& isOrderIdentifier(value.orderId)
&& isOrderIdentifier(value.bizCode)
&& isOrderIdentifier(value.contractId)
&& isOrderIdentifier(value.id)
&& isOrderIdentifier(value.tenant)
&& isOrderAmount(value.orderAmount)
&& isNonBlankTextBounded(value.orderAmountCurrency, 64)
&& isOrderAmount(value.paymentAmount)
&& isNonBlankTextBounded(value.paymentAmountCurrency, 64)
&& isNonBlankTextBounded(value.statusMessageKey, 64 * 1024)
&& Array.isArray(value.actions)
&& value.actions.length <= 100
&& value.actions.every(isOrderAction);
return false;
};
const isCenterMessageBase = (value) => isRecord(value)
+1 -21
View File
@@ -1,7 +1,7 @@
// 定义 OneTalk 联系人资料摄入协议
import type { OneTalkPluginScope } from "./connection.ts";
import { isPlainRecord } from "./guards.ts";
import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
import { ONETALK_PROTOCOL_VERSION } from "./wire.ts";
import type { OneTalkBaseFrame, OneTalkFrameContext } from "./wire.ts";
@@ -10,7 +10,6 @@ export type OneTalkContactProfileObservationStatus =
(typeof ONETALK_CONTACT_PROFILE_STATUSES)[number];
export const ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE = 100;
export const ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES = 256 * 1024;
const ONETALK_AVATAR_URL_PROTOCOLS = ["http:", "https:"] as const;
export type OneTalkContactProfile = {
conversationId: string;
aliId: string;
@@ -78,25 +77,6 @@ const hasExactProfileFrameKeys = (value: Record<string, unknown>): boolean => {
);
};
/** 判断头像是否为不含空白的绝对 HTTP(S) URL。 */
export const isOneTalkAvatarUrl = (value: unknown): value is string => {
if (
typeof value !== "string" ||
value.length === 0 ||
value.trim() !== value ||
/\s/u.test(value)
)
return false;
try {
const url = new URL(value);
return (
ONETALK_AVATAR_URL_PROTOCOLS.includes(url.protocol as never) && url.hostname.length > 0
);
} catch {
return false;
}
};
/** 严格验证联系人资料帧的业务 payload。 */
export const isValidOneTalkContactProfilePayload = (
type: string,
+154 -3
View File
@@ -1,12 +1,19 @@
// 定义 OneTalk 归一化消息内容和 Mind 读取模型
import { isPlainRecord } from "./guards.ts";
import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts";
export const ONETALK_CONTENT_VERSION = 1 as const;
export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3;
export const ONETALK_CONTENT_KINDS = ["text", "image", "file"] as const;
export const ONETALK_CONTENT_KINDS = [
"text",
"image",
"file",
"business_card",
"inquiry",
"order",
] as const;
export type OneTalkMessageContentKind = (typeof ONETALK_CONTENT_KINDS)[number];
export type OneTalkTextContent = {
@@ -43,7 +50,51 @@ export type OneTalkFileContent = {
urlScope: "onetalk_session";
};
export type OneTalkMessageContent = OneTalkTextContent | OneTalkImageContent | OneTalkFileContent;
/** 消息时点的会话联系人资料快照,不是当前联系人 profile。 */
export type OneTalkBusinessCardContent = {
version: typeof ONETALK_CONTENT_VERSION;
kind: "business_card";
contactName: string | null;
companyName: string | null;
countryCode: string | null;
avatarUrl: string | null;
};
/** 已观察 SDK 历史询盘卡的分类标记;本期不采集其业务字段。 */
export type OneTalkInquiryContent = {
version: typeof ONETALK_CONTENT_VERSION;
kind: "inquiry";
};
export type OneTalkOrderAction = {
name: string;
messageKey: string;
payStep: string | null;
};
export type OneTalkOrderContent = {
version: typeof ONETALK_CONTENT_VERSION;
kind: "order";
orderId: string | number | null;
bizCode: string | number | null;
contractId: string | number | null;
id: string | number | null;
tenant: string | number | null;
orderAmount: number;
orderAmountCurrency: string;
paymentAmount: number;
paymentAmountCurrency: string;
statusMessageKey: string;
actions: OneTalkOrderAction[];
};
export type OneTalkMessageContent =
| OneTalkTextContent
| OneTalkImageContent
| OneTalkFileContent
| OneTalkBusinessCardContent
| OneTalkInquiryContent
| OneTalkOrderContent;
/** Mind HTTP 会话与 conversation.updated 共用的公开会话读取模型。 */
export type CenterConversation = {
@@ -76,6 +127,7 @@ const MAX_FILE_NAME_LENGTH = 255;
const MAX_EXTENSION_LENGTH = 64;
const MAX_MD5_LENGTH = 128;
const MAX_MEDIA_URL_LENGTH = 8 * 1024;
const MAX_ORDER_ACTIONS = 100;
const CONTENT_KEYS = {
text: ["version", "kind", "text"],
@@ -105,8 +157,27 @@ const CONTENT_KEYS = {
"downloadState",
"urlScope",
],
business_card: ["version", "kind", "contactName", "companyName", "countryCode", "avatarUrl"],
inquiry: ["version", "kind"],
order: [
"version",
"kind",
"orderId",
"bizCode",
"contractId",
"id",
"tenant",
"orderAmount",
"orderAmountCurrency",
"paymentAmount",
"paymentAmountCurrency",
"statusMessageKey",
"actions",
],
} as const;
const ORDER_ACTION_KEYS = ["name", "messageKey", "payStep"] as const;
const CENTER_MESSAGE_KEYS = [
"messageId",
"conversationId",
@@ -194,6 +265,32 @@ const isMd5 = (value: unknown): value is string | null => {
return value === null || isNonBlankString(value, MAX_MD5_LENGTH);
};
const isNullableText = (value: unknown): value is string | null => {
return value === null || isNonBlankText(value, MAX_TEXT_LENGTH);
};
const isOrderIdentifier = (value: unknown): value is string | number | null => {
return (
value === null ||
isNonBlankString(value, MAX_IDENTIFIER_LENGTH) ||
(typeof value === "number" && Number.isSafeInteger(value))
);
};
const isOrderAmount = (value: unknown): value is number => {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
};
const isOneTalkOrderAction = (value: unknown): value is OneTalkOrderAction => {
return (
isPlainRecord(value) &&
hasExactKeys(value, ORDER_ACTION_KEYS) &&
isNonBlankText(value.name, MAX_TEXT_LENGTH) &&
isNonBlankText(value.messageKey, MAX_TEXT_LENGTH) &&
isNullableText(value.payStep)
);
};
const hasAllowedMediaQuery = (url: URL): boolean => {
const seenKeys = new Set<string>();
for (const [key, value] of url.searchParams) {
@@ -306,6 +403,51 @@ const isOneTalkFileContent = (value: Record<string, unknown>): value is OneTalkF
);
};
const isOneTalkBusinessCardContent = (
value: Record<string, unknown>,
): value is OneTalkBusinessCardContent => {
return (
hasExactKeys(value, CONTENT_KEYS.business_card) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "business_card" &&
isNullableText(value.contactName) &&
isNullableText(value.companyName) &&
isNullableText(value.countryCode) &&
(value.avatarUrl === null || isOneTalkAvatarUrl(value.avatarUrl))
);
};
const isOneTalkInquiryContent = (
value: Record<string, unknown>,
): value is OneTalkInquiryContent => {
return (
hasExactKeys(value, CONTENT_KEYS.inquiry) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "inquiry"
);
};
const isOneTalkOrderContent = (value: Record<string, unknown>): value is OneTalkOrderContent => {
return (
hasExactKeys(value, CONTENT_KEYS.order) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "order" &&
isOrderIdentifier(value.orderId) &&
isOrderIdentifier(value.bizCode) &&
isOrderIdentifier(value.contractId) &&
isOrderIdentifier(value.id) &&
isOrderIdentifier(value.tenant) &&
isOrderAmount(value.orderAmount) &&
isNonBlankText(value.orderAmountCurrency, MAX_EXTENSION_LENGTH) &&
isOrderAmount(value.paymentAmount) &&
isNonBlankText(value.paymentAmountCurrency, MAX_EXTENSION_LENGTH) &&
isNonBlankText(value.statusMessageKey, MAX_TEXT_LENGTH) &&
Array.isArray(value.actions) &&
value.actions.length <= MAX_ORDER_ACTIONS &&
value.actions.every(isOneTalkOrderAction)
);
};
/** 解码 exact-shape 的已归一化 OneTalk 消息内容。 */
export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageContentDecodeResult => {
if (!isPlainRecord(value) || value.version !== ONETALK_CONTENT_VERSION) return { ok: false };
@@ -319,6 +461,15 @@ export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageConte
if (value.kind === "file" && isOneTalkFileContent(value)) {
return { ok: true, content: value };
}
if (value.kind === "business_card" && isOneTalkBusinessCardContent(value)) {
return { ok: true, content: value };
}
if (value.kind === "inquiry" && isOneTalkInquiryContent(value)) {
return { ok: true, content: value };
}
if (value.kind === "order" && isOneTalkOrderContent(value)) {
return { ok: true, content: value };
}
return { ok: false };
};
+18
View File
@@ -6,3 +6,21 @@ export const isPlainRecord = (value: unknown): value is Record<string, unknown>
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
/** 判断 OneTalk 白名单资料头像是否为不含空白的绝对 HTTP(S) URL。 */
export const isOneTalkAvatarUrl = (value: unknown): value is string => {
if (
typeof value !== "string" ||
value.length === 0 ||
value.trim() !== value ||
/\s/u.test(value)
) {
return false;
}
try {
const url = new URL(value);
return (url.protocol === "http:" || url.protocol === "https:") && url.hostname.length > 0;
} catch {
return false;
}
};
+1 -2
View File
@@ -1,6 +1,6 @@
// 暴露 OneTalk 跨包公共协议边界
export { isPlainRecord } from "./guards.ts";
export { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
export * from "./authorization.ts";
export * from "./content.ts";
export * from "./decoder.ts";
@@ -122,7 +122,6 @@ export {
ONETALK_CONTACT_PROFILE_STATUSES,
ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE,
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES,
isOneTalkAvatarUrl,
isOneTalkContactProfile,
createOneTalkContactProfileObservedFrame,
createOneTalkContactProfileAckFrame,
@@ -164,6 +164,36 @@ const pdfContent = {
urlScope: "onetalk_session" as const,
};
const businessCardContent = {
version: ONETALK_CONTENT_VERSION,
kind: "business_card" as const,
contactName: "Buyer Name",
companyName: "Buyer Company",
countryCode: "CN",
avatarUrl: "https://cdn.example.test/avatar/buyer.jpg",
};
const inquiryContent = {
version: ONETALK_CONTENT_VERSION,
kind: "inquiry" as const,
};
const orderContent = {
version: ONETALK_CONTENT_VERSION,
kind: "order" as const,
orderId: "order-1",
bizCode: -42,
contractId: "contract-1",
id: "id-1",
tenant: "tenant-1",
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actions: [{ name: "pay", messageKey: "order.pay", payStep: "deposit" }],
};
const observedMessage = {
messageId: "message-1",
conversationId: "conversation-1",
@@ -1095,6 +1125,29 @@ test("validates normalized text, JPEG, ZIP, and PDF content with exact metadata"
assert.equal(imageSendCommand.type, "send.command");
});
test("validates exact business-card, inquiry, and order content without raw card fields", () => {
for (const content of [businessCardContent, inquiryContent, orderContent]) {
assert.equal(isOneTalkMessageContent(content), true);
assert.deepEqual(decodeOneTalkMessageContent(content), { ok: true, content });
assert.equal(isOneTalkMessage({ ...observedMessage, content }), true);
}
for (const content of [
{ ...businessCardContent, contactName: "" },
{ ...businessCardContent, avatarUrl: " https://cdn.example.test/avatar/buyer.jpg" },
{ ...businessCardContent, contact: { accountIdEncrypt: "secret" } },
{ ...inquiryContent, params: { encryFeedbackId: "secret" } },
{ ...orderContent, orderAmount: -1 },
{ ...orderContent, orderAmountCurrency: "" },
{ ...orderContent, actions: [{ ...orderContent.actions[0], sign: "secret" }] },
{ ...orderContent, actions: [{ name: "pay", messageKey: "order.pay", payStep: 1 }] },
{ ...orderContent, params: { sign: "secret" } },
]) {
assert.equal(isOneTalkMessageContent(content), false);
assert.deepEqual(decodeOneTalkMessageContent(content), { ok: false });
}
});
test("rejects legacy image dimensions as exact-shape extras", () => {
for (const [key, value] of [
["width", 1_280],
@@ -0,0 +1,2 @@
ALTER TABLE "onetalk_message" DROP CONSTRAINT "onetalk_message_content_v1_chk";--> statement-breakpoint
ALTER TABLE "onetalk_message" ADD CONSTRAINT "onetalk_message_content_v1_chk" CHECK (jsonb_typeof("onetalk_message"."content") = 'object' and ("onetalk_message"."content" ->> 'version') = '1' and ("onetalk_message"."content" ->> 'kind') in ('text', 'image', 'file', 'business_card', 'inquiry', 'order'));
+850
View File
@@ -0,0 +1,850 @@
{
"id": "e2524a45-905a-4575-a57a-49b6f1c0c30a",
"prevId": "d5baf5ff-2b43-4661-a475-8801262dadd4",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.onetalk_buyer_fact": {
"name": "onetalk_buyer_fact",
"schema": "",
"columns": {
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"buyer_tags": {
"name": "buyer_tags",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"buyer_features": {
"name": "buyer_features",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"registration_date": {
"name": "registration_date",
"type": "text",
"primaryKey": false,
"notNull": false
},
"company_website": {
"name": "company_website",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tags_state": {
"name": "tags_state",
"type": "onetalk_buyer_fact_source_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"features_state": {
"name": "features_state",
"type": "onetalk_buyer_fact_source_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"contact_details_state": {
"name": "contact_details_state",
"type": "onetalk_buyer_fact_source_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"tags_last_attempted_at": {
"name": "tags_last_attempted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"features_last_attempted_at": {
"name": "features_last_attempted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"contact_details_last_attempted_at": {
"name": "contact_details_last_attempted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"tags_confirmed_at": {
"name": "tags_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"features_confirmed_at": {
"name": "features_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"contact_details_confirmed_at": {
"name": "contact_details_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"tags_error_code": {
"name": "tags_error_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"features_error_code": {
"name": "features_error_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"contact_details_error_code": {
"name": "contact_details_error_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fact_fingerprint": {
"name": "fact_fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"received_at": {
"name": "received_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_buyer_fact_channel_account_id_conversation_id_pk": {
"name": "onetalk_buyer_fact_channel_account_id_conversation_id_pk",
"columns": ["channel_account_id", "conversation_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.onetalk_contact_profile": {
"name": "onetalk_contact_profile",
"schema": "",
"columns": {
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ali_id": {
"name": "ali_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"login_id": {
"name": "login_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country_code": {
"name": "country_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"current_time_zone": {
"name": "current_time_zone",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"service_type": {
"name": "service_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"observed_at_ms": {
"name": "observed_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"profile_fingerprint": {
"name": "profile_fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"observation_status": {
"name": "observation_status",
"type": "onetalk_contact_profile_observation_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"received_at": {
"name": "received_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_contact_profile_channel_account_id_conversation_id_pk": {
"name": "onetalk_contact_profile_channel_account_id_conversation_id_pk",
"columns": ["channel_account_id", "conversation_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.onetalk_conversation": {
"name": "onetalk_conversation",
"schema": "",
"columns": {
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"participant_ids": {
"name": "participant_ids",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"biz_type": {
"name": "biz_type",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"conversation_type": {
"name": "conversation_type",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"conversation_kind": {
"name": "conversation_kind",
"type": "text",
"primaryKey": false,
"notNull": false
},
"join_time_ms": {
"name": "join_time_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"modify_time_ms": {
"name": "modify_time_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"last_message_at_ms": {
"name": "last_message_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"last_contact_time_ms": {
"name": "last_contact_time_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"message_preview": {
"name": "message_preview",
"type": "text",
"primaryKey": false,
"notNull": false
},
"sync_phase": {
"name": "sync_phase",
"type": "onetalk_sync_phase",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'initial'"
},
"sync_result": {
"name": "sync_result",
"type": "onetalk_sync_result",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'incomplete'"
},
"latest_message_id": {
"name": "latest_message_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"history_complete": {
"name": "history_complete",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"message_count": {
"name": "message_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"first_discovered_at": {
"name": "first_discovered_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_observed_at": {
"name": "last_observed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"anchor_updated_at": {
"name": "anchor_updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"onetalk_conversation_last_observed_idx": {
"name": "onetalk_conversation_last_observed_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "last_observed_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"onetalk_conversation_contact_time_idx": {
"name": "onetalk_conversation_contact_time_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "last_contact_time_ms",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_conversation_channel_account_id_conversation_id_pk": {
"name": "onetalk_conversation_channel_account_id_conversation_id_pk",
"columns": ["channel_account_id", "conversation_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.onetalk_message": {
"name": "onetalk_message",
"schema": "",
"columns": {
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sender_id": {
"name": "sender_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"binding": {
"name": "binding",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mind_user_id": {
"name": "mind_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"direction": {
"name": "direction",
"type": "onetalk_message_direction",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"observation_type": {
"name": "observation_type",
"type": "onetalk_observation_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"sent_at_ms": {
"name": "sent_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"participant_ids": {
"name": "participant_ids",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"read_status": {
"name": "read_status",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"message_status": {
"name": "message_status",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"unread_count": {
"name": "unread_count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"first_observed_at": {
"name": "first_observed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_observed_at": {
"name": "last_observed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"onetalk_message_conversation_time_idx": {
"name": "onetalk_message_conversation_time_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sent_at_ms",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "message_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"onetalk_message_sender_idx": {
"name": "onetalk_message_sender_idx",
"columns": [
{
"expression": "sender_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_message_channel_account_id_conversation_id_message_id_pk": {
"name": "onetalk_message_channel_account_id_conversation_id_message_id_pk",
"columns": ["channel_account_id", "conversation_id", "message_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"onetalk_message_content_v1_chk": {
"name": "onetalk_message_content_v1_chk",
"value": "jsonb_typeof(\"onetalk_message\".\"content\") = 'object' and (\"onetalk_message\".\"content\" ->> 'version') = '1' and (\"onetalk_message\".\"content\" ->> 'kind') in ('text', 'image', 'file', 'business_card', 'inquiry', 'order')"
}
},
"isRLSEnabled": false
},
"public.onetalk_message_anomaly": {
"name": "onetalk_message_anomaly",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"anomaly_type": {
"name": "anomaly_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"binding": {
"name": "binding",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mind_user_id": {
"name": "mind_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"missing_fields": {
"name": "missing_fields",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"observation_source": {
"name": "observation_source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "onetalk_anomaly_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'open'"
},
"occurrence_count": {
"name": "occurrence_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"first_seen_at": {
"name": "first_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"onetalk_message_anomaly_fingerprint_uidx": {
"name": "onetalk_message_anomaly_fingerprint_uidx",
"columns": [
{
"expression": "fingerprint",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"onetalk_message_anomaly_scope_idx": {
"name": "onetalk_message_anomaly_scope_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.onetalk_anomaly_status": {
"name": "onetalk_anomaly_status",
"schema": "public",
"values": ["open", "resolved", "ignored"]
},
"public.onetalk_buyer_fact_source_state": {
"name": "onetalk_buyer_fact_source_state",
"schema": "public",
"values": ["pending", "confirmed", "failed"]
},
"public.onetalk_contact_profile_observation_status": {
"name": "onetalk_contact_profile_observation_status",
"schema": "public",
"values": ["confirmed", "partial"]
},
"public.onetalk_message_direction": {
"name": "onetalk_message_direction",
"schema": "public",
"values": ["sent", "received"]
},
"public.onetalk_observation_type": {
"name": "onetalk_observation_type",
"schema": "public",
"values": ["new", "history"]
},
"public.onetalk_sync_phase": {
"name": "onetalk_sync_phase",
"schema": "public",
"values": ["initial", "incremental"]
},
"public.onetalk_sync_result": {
"name": "onetalk_sync_result",
"schema": "public",
"values": ["succeeded", "succeeded_with_anomalies", "failed", "incomplete"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -71,6 +71,13 @@
"when": 1789102776000,
"tag": "0009_remove_image_dimensions",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1789120804420,
"tag": "0010_wooden_naoko",
"breakpoints": true
}
]
}
+1 -1
View File
@@ -113,7 +113,7 @@ export const onetalkMessage = pgTable(
index("onetalk_message_sender_idx").on(table.senderId),
check(
"onetalk_message_content_v1_chk",
sql`jsonb_typeof(${table.content}) = 'object' and (${table.content} ->> 'version') = '1' and (${table.content} ->> 'kind') in ('text', 'image', 'file')`,
sql`jsonb_typeof(${table.content}) = 'object' and (${table.content} ->> 'version') = '1' and (${table.content} ->> 'kind') in ('text', 'image', 'file', 'business_card', 'inquiry', 'order')`,
),
],
);
+28
View File
@@ -366,6 +366,34 @@ test("persists a valid normalized content object without server-side reinterpret
assert.deepEqual(result.message.content, { version: 1, kind: "text", text: "hello" });
});
test("persists an approved structured order without server-side card parsing", async () => {
const harness = createRepositoryHarness();
const service = createOneTalkService(harness.repository);
await service.discoverConversation(context, "conversation-1", undefined, "direct");
const content = {
version: 1 as const,
kind: "order" as const,
orderId: "order-1",
bizCode: 42,
contractId: "contract-1",
id: "id-1",
tenant: "tenant-1",
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actions: [{ name: "pay", messageKey: "order.pay", payStep: "deposit" }],
};
const result = await service.observeMessage(context, "history", message({ content }));
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(result.message.content, content);
assert.equal(JSON.stringify(result).includes("sign"), false);
});
test("advances only valid shared anchors and preserves incomplete outcomes", async () => {
const emptyHarness = createRepositoryHarness();
const emptyService = createOneTalkService(emptyHarness.repository);
+20 -1
View File
@@ -494,7 +494,7 @@ test("projects one persisted media fact identically for history and message.crea
assert.equal("text" in historyMessage, false);
});
test("CenterMessage accepts normalized image and file values", () => {
test("CenterMessage accepts normalized media and structured-card values", () => {
const verifiedImage = {
...messageFieldsForProjectionTest(),
content: {
@@ -527,9 +527,28 @@ test("CenterMessage accepts normalized image and file values", () => {
urlScope: "onetalk_session",
},
} satisfies CenterMessage;
const verifiedOrder = {
...messageFieldsForProjectionTest(),
content: {
version: 1,
kind: "order",
orderId: "order-1",
bizCode: 42,
contractId: "contract-1",
id: "id-1",
tenant: "tenant-1",
orderAmount: 12.5,
orderAmountCurrency: "USD",
paymentAmount: 10,
paymentAmountCurrency: "USD",
statusMessageKey: "order.pending_payment",
actions: [{ name: "pay", messageKey: "order.pay", payStep: "deposit" }],
},
} satisfies CenterMessage;
assert.equal(verifiedImage.content.kind, "image");
assert.equal(verifiedFile.content.fileName, "quote.pdf");
assert.equal(verifiedOrder.content.statusMessageKey, "order.pending_payment");
});
test("rejects persisted image content with retired dimensions at the read boundary", () => {
@@ -0,0 +1,96 @@
# OneTalk 名片消息:运行态格式观察
> 观察日期:2026-09-11
> 证据边界:现有已登录 OneTalk PWA 的 Chromium CDP,只读调用页面 SDK;不保存或输出原始消息、联系人资料、令牌、加密标识或正文。
> 性质:单个真实样本的运行态观察,不是当前跨层数据合同。
## 1. 怎么判断是名片
SDK 历史条目的名片判别条件为:
```ts
const isBusinessCardMessage = (message: Record<string, unknown>): boolean =>
message.messageType === "rec" &&
message.type === 1 &&
message.viewType === 0 &&
message.msgType === 10010 &&
message.subType === 57 &&
message.originalData?.cardType === 1;
```
这里的联合条件不可缩减成 `msgType=10010`:附件、询盘和订单也使用该 `msgType`
## 2. JSON 中已有的数据
### 2.1 名片卡片参数
```text
originalData.cardType = 1
originalData.params keys =
ctime, from, showCertifications, showCompanyName,
showEmailAddress, sign, to
```
`showCompanyName``showEmailAddress``showCertifications` 是展示开关;它们不是公司名、邮箱或认证详情本身。
### 2.2 会话联系人对象
同一条 SDK 条目的 `contact` 中可观察到以下候选字段:
```text
accountId, accountIdEncrypt, aliId, aliIdEncrypt,
loginId, loginIdEncrypt, name, fullPortrait,
companyName, complianceCountryCode, currentTimeZone, serviceType
```
可谨慎使用的资料含义如下:
| 字段 | 可表达的信息 | 限制 |
| ------------------------------- | ------------- | ---------------------------------------- |
| `contact.name` | 联系人显示名 | 是会话联系人资料,不能证明是名片固定字段 |
| `contact.companyName` | 联系人公司名 | 可为空或滞后 |
| `contact.complianceCountryCode` | 国家/地区代码 | 国旗由 UI 按代码渲染,不是消息图片 |
| `contact.fullPortrait` | 头像候选 URL | 本样本未填;不能假设必有 |
截图中的邮箱没有观察到独立的 `email` JSON 字段。本样本 `content` 是非 JSON 的普通字符串;邮箱可能出现在其中的展示文本,但没有验证出可复用的字段格式。
`extInfo.icbuData` 同样只有 `chatEvent` 有实际值,`title``iconUrl``cardUrls``actions``defaultContent` 都不可直接使用。
## 3. 怎么获取
名片和其它业务卡共享同一只读历史入口;区别只在过滤条件。
```js
const getBusinessCardMessages = async (conversation) => {
const service = window.IcbuIM.IMBaaSSDK.default.getMessageService();
const response = await service.fetchMessagesWithoutUpdateToRead(
{
conversationCode: conversation.cid,
contactAccountId: conversation.accountId,
contactAccountIdEncrypt: conversation.accountIdEncrypt,
aliId: conversation.aliId,
aliIdEncrypt: conversation.aliIdEncrypt,
searchMessageId: "",
timeSlide: { forward: false, timeStamp: Date.now(), pageSize: 20 },
},
conversation,
);
return (response.list ?? []).filter(
(message) =>
message.messageType === "rec" &&
message.type === 1 &&
message.viewType === 0 &&
message.msgType === 10010 &&
message.subType === 57 &&
message.originalData?.cardType === 1,
);
};
```
如果后续业务需要联系人名称、公司、国家代码或头像,必须明确它们是 `contact` 资料观察,不是名片内容的权威声明。跨层同步只应传递经业务批准的白名单字段;不能透传 `contact` 整体对象、加密标识、`chatToken``content``sign`
## 4. 已验证与未覆盖
- 已验证:`10010/57/cardType=1` 判别组合,`originalData.params` 键集合,联系人候选资料和 `icbuData` 可用性。
- 未覆盖:`content` 的名片展示文本格式,独立邮箱字段来源,头像字段在不同名片中的填充率,以及名片详情/跳转链接。
@@ -0,0 +1,101 @@
# OneTalk 询盘消息:运行态格式观察
> 观察日期:2026-09-11
> 证据边界:现有已登录 OneTalk PWA 的 Chromium CDP,只读调用页面 SDK;不切换会话、不更新已读状态、不保存或输出原始消息、令牌、加密标识、媒体 URL 或正文。
> 性质:单个真实样本的运行态观察,不是 OneTalk 全局类型枚举,也不是当前跨层数据合同。
## 1. 怎么判断是询盘
SDK 历史返回的单条扁平消息必须同时满足以下条件:
```ts
const isInquiryMessage = (message: Record<string, unknown>): boolean =>
message.messageType === "rec" &&
message.type === 1 &&
message.viewType === 0 &&
message.msgType === 10010 &&
message.subType === 50 &&
message.originalData?.cardType === 6;
```
其中 `msgType=10010` 不是充分条件:当前已观察到的附件、名片和订单同样使用它。
| 类型 | `msgType` | `subType` | `originalData.cardType` |
| ---- | --------: | --------: | ----------------------: |
| 询盘 | 10010 | 50 | 6 |
| 名片 | 10010 | 57 | 1 |
| 订单 | 10010 | 59 | 9 |
| 附件 | 10010 | 61 | 12 |
## 2. JSON 中已有的数据
SDK 返回是 `{ hasMore, list }`,本样本的询盘条目具有以下顶层字段:
```text
autoReply, contact, contactRead, content, conversationCode, extInfo,
localExt, messageId, messageType, msgType, opId, originExt,
originalData, owner, receiver, sendTime, sender, spamStatus, status,
subType, type, unread, uuid, viewType
```
询盘专有的稳定结构位于 `originalData`
```text
originalData.cardType = 6
originalData.params keys =
ctime, encryFeedbackId, encryTradeId, fbType, from, marketType,
sign, source, to, version
```
这些字段可用于将消息关联到询盘/贸易实体,但 `encryFeedbackId``encryTradeId``sign` 属于不应跨 MAIN-world 边界的敏感原始值。
`extInfo.icbuData` 具有 `actions``cardUrls``title``iconUrl``defaultContent``chatEvent` 等候选键名;本样本实际只有 `chatEvent` 有值。不能把键存在误认为标题、商品图片或详情链接可直接取得。
本样本的 `content` 是普通字符串,不是 JSON、URI-JSON 或 Base64-JSON。它不是稳定的业务字段合同。
## 3. 展示卡与 SDK 字段的差异
页面渲染的询盘卡可显示商品标题、采购量、需求、商品图片和按钮,但这些并非本次 SDK 对象中直接可用的结构化字段:
- 商品标题、采购量、详细需求和图片可以在**已渲染且当前可见**的卡片 DOM 中读取;这只是 UI 观察,不可代替消息事实源。
- 本样本的 SDK `cardUrls` 为空,卡片内也没有静态 `<a href>`;“查看详情 / 立即报价”由前端点击逻辑生成,不能从原始消息直接宣称存在详情链接。
- 不应通过 DOM 抓取的展示文本反推稳定的后端协议字段。
## 4. 怎么获取
在 OneTalk PWA 的 MAIN world 中,从精确会话对象调用只读历史方法。不要从昵称、列表顺序或页面文本推断 `conversationId`
```js
const getInquiryMessages = async (conversation) => {
const service = window.IcbuIM.IMBaaSSDK.default.getMessageService();
const response = await service.fetchMessagesWithoutUpdateToRead(
{
conversationCode: conversation.cid,
contactAccountId: conversation.accountId,
contactAccountIdEncrypt: conversation.accountIdEncrypt,
aliId: conversation.aliId,
aliIdEncrypt: conversation.aliIdEncrypt,
searchMessageId: "",
timeSlide: { forward: false, timeStamp: Date.now(), pageSize: 20 },
},
conversation,
);
return (response.list ?? []).filter(
(message) =>
message.messageType === "rec" &&
message.type === 1 &&
message.viewType === 0 &&
message.msgType === 10010 &&
message.subType === 50 &&
message.originalData?.cardType === 6,
);
};
```
这个函数的返回值只应在 MAIN world 中用于归类或进一步受控解析。跨页面桥、Service Worker、IndexedDB、Bright 或 Mind 时,应传递白名单化的业务字段或明确的 `unsupported` 结果,绝不能传递整个 `message``content``params``chatToken` 或序列化的原始对象。
## 5. 已验证与未覆盖
- 已验证:上述判别组合、`originalData.params` 键集合、`icbuData` 字段可用性、单页 SDK 返回形态。
- 未覆盖:询盘 `content` 的稳定文本格式、商品详情 API、详情链接生成规则、不同站点/订单状态下的变体。
@@ -0,0 +1,130 @@
# OneTalk 订单消息:运行态格式观察
> 观察日期:2026-09-11
> 证据边界:现有已登录 OneTalk PWA 的 Chromium CDP,只读调用页面 SDK;不保存或输出原始订单、地址、令牌、加密标识、消息正文或完整 URL。
> 性质:单个真实样本的运行态观察,不是订单系统接口契约,也不是当前跨层数据合同。
## 1. 怎么判断是订单
SDK 历史条目的订单判别条件为:
```ts
const isOrderMessage = (message: Record<string, unknown>): boolean =>
message.messageType === "rec" &&
message.type === 1 &&
message.viewType === 0 &&
message.msgType === 10010 &&
message.subType === 59 &&
message.originalData?.cardType === 9;
```
`msgType=10010` 是业务卡片族而不是订单标记;必须同时检查 `subType=59``cardType=9`
## 2. JSON 中已有的数据
### 2.1 订单关联字段
```text
originalData.cardType = 9
originalData.params keys =
orderId, bizCode, contractId, sign, ctime,
from, to, id, params, tenant
```
`orderId``contractId``id``bizCode` 可用于同一受控边界内的订单关联。`sign`、参与方标识和加密/令牌型字段不得跨 MAIN world 传输或持久化。
### 2.2 Base64 订单摘要
`originalData.params.params` 在本样本中是 Base64 编码的 UTF-8 JSON。解码后具有以下结构:
```ts
type OrderSummary = {
id: number;
orderAmount: number;
orderAmountCurrency: string;
paymentAmount: number;
paymentAmountCurrency: string;
statusMessageKey: string;
actionList: Array<{
name: string;
messageKey: string;
properties: {
payStep?: string;
};
}>;
};
```
因此当前可直接获得:
- 订单和实付金额及币种;
- 非本地化的订单状态键 `statusMessageKey`
- 动作列表及部分付款阶段 `payStep`
- 订单关联 ID。
### 2.3 当前不能从结构化摘要直接获得的数据
样本 UI 显示的商品数量、商品明细/图片、人类可读状态、收件地址和详情链接不在上述已解码摘要内。
本样本 `content` 为非 JSON 的普通字符串模板。它不能直接作为稳定 schema 使用,也不能未经白名单处理跨层传递。
`extInfo.icbuData` 中的 `title``iconUrl``cardUrls``actions``defaultContent` 在本样本为空;只有 `chatEvent` 有值,不能据此承诺订单详情链接可用。
## 3. 怎么获取
### 3.1 取得订单消息
```js
const getOrderMessages = async (conversation) => {
const service = window.IcbuIM.IMBaaSSDK.default.getMessageService();
const response = await service.fetchMessagesWithoutUpdateToRead(
{
conversationCode: conversation.cid,
contactAccountId: conversation.accountId,
contactAccountIdEncrypt: conversation.accountIdEncrypt,
aliId: conversation.aliId,
aliIdEncrypt: conversation.aliIdEncrypt,
searchMessageId: "",
timeSlide: { forward: false, timeStamp: Date.now(), pageSize: 20 },
},
conversation,
);
return (response.list ?? []).filter(
(message) =>
message.messageType === "rec" &&
message.type === 1 &&
message.viewType === 0 &&
message.msgType === 10010 &&
message.subType === 59 &&
message.originalData?.cardType === 9,
);
};
```
### 3.2 在 MAIN world 内解码摘要
必须限制大小、验证 Base64/UTF-8/JSON 和字段 schema;示例仅展示解码入口,不构成跨层透传授权。
```js
const decodeOrderSummary = (encoded) => {
if (typeof encoded !== "string" || encoded.length === 0) return null;
try {
const bytes = Uint8Array.from(atob(encoded), (character) => character.charCodeAt(0));
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
if (!parsed || typeof parsed !== "object") return null;
return parsed;
} catch {
return null;
}
};
```
生产实现不能将 `null` 静默当作“无订单”:Base64、UTF-8、JSON 或 schema 失败应成为可观测的显式异常/受控 `unsupported` 结果。
## 4. 已验证与未覆盖
- 已验证:`10010/59/cardType=9` 判别组合,订单参数键,嵌套 `params` 的 Base64-UTF-8-JSON 编码,以及摘要键集合。
- 未覆盖:商品数量和明细、图片、状态键到本地化文案的映射、收件地址、详情链接、不同订单状态及多商品订单的字段变体。