mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: normalize OneTalk business card content
This commit is contained in:
@@ -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 不变、每一种订单解码失败和敏感字段未越界。
|
||||
- server:wire/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 字段。
|
||||
- WebSocket:Mind 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 后读取仍按账号/会话隔离。
|
||||
|
||||
+12
@@ -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 排除和未覆盖业务字段。"}
|
||||
+102
@@ -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. server:wire guard、repository copy、JSONB migration/check、读投影和 publish 均接受新 kind;复合幂等键、ACK 和旧 content 回归。
|
||||
5. quality:定向测试、typecheck、build、全量测试、format check 与 migration check;真实 Chromium 只作为未来历史样本 smoke,WebSocket raw 不在本期验收。
|
||||
+14
@@ -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 排除边界。"}
|
||||
+64
@@ -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 anomaly,raw 不越界 |
|
||||
| 名片快照 | 覆盖当前 contact profile 或复制完整 `contact` | 仅四个字段进入 message content;profile 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。
|
||||
+49
@@ -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 字段范围、兼容策略、测试边界及未覆盖的运行态变体。
|
||||
+32
@@ -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"
|
||||
}
|
||||
}
|
||||
+43
@@ -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,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,
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
|
||||
@@ -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,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'));
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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')`,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 编码,以及摘要键集合。
|
||||
- 未覆盖:商品数量和明细、图片、状态键到本地化文案的映射、收件地址、详情链接、不同订单状态及多商品订单的字段变体。
|
||||
Reference in New Issue
Block a user