mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
docs(onetalk): document media message formats and sync plan
This commit is contained in:
@@ -0,0 +1,489 @@
|
||||
# OneTalk 图片与附件消息格式调查报告
|
||||
|
||||
> 调查日期:2026-09-01
|
||||
> 调查对象:`https://onetalk.alibaba.com/message/weblitePWA.htm` 以及当前 `trade-message-center` OneTalk 扩展链路
|
||||
> 调查方式:Chromium DevTools Protocol(CDP,`127.0.0.1:9222`)只读运行时探查、历史 WebSocket 帧捕获、已加载 SDK bundle 静态检索、仓库代码追踪
|
||||
> 安全边界:本报告不保存或展示 Cookie、`sid`、`chatToken`、加密账号、签名 URL、消息正文和二进制内容;示例只保留字段名、类型和脱敏结构。
|
||||
|
||||
## 1. 摘要
|
||||
|
||||
OneTalk 的图片和附件并不是另一条独立的同步通道。它们和文本一样出现在历史消息 WebSocket 帧的 `body.userMessageModels[].message` 中,但内容形态不同:
|
||||
|
||||
- 文本:`contentType = 1`,正文在 `content.text.content`。
|
||||
- 图片:`contentType = 101`,`content.custom.type = 7`,`content.custom.data` 是 Base64 编码的 JSON。
|
||||
- 附件:`contentType = 101`,`content.custom.type = 10010`,`content.custom.data` 是 Base64 编码的 JSON。
|
||||
|
||||
当前扩展的传输和持久化边界已经能够保留这些原始内容;非文本消息只会令便利字段 `text` 为 `null`,不会令 `content` 消失。因此“现在只实现 text”的准确含义是:当前没有完成图片/附件的语义投影、Mind 端展示和完整发送适配,而不是 WebSocket 接收层完全收不到媒体。
|
||||
|
||||
本次 CDP 实测在当前登录页面的两个已加载会话中调用了只读历史接口,捕获到一页 20 条消息的历史 WebSocket 帧;现有 `parseOneTalkMessages` 返回了全部 20 条,其中包含一条图片和一条附件。没有点击上传、发送或下载,发送侧结论只来自 SDK 和 bundle 的方法/调用形态分析。
|
||||
|
||||
## 2. 调查范围与证据等级
|
||||
|
||||
### 2.1 已直接验证
|
||||
|
||||
1. Chromium CDP 调试端口 `127.0.0.1:9222` 可用,目标页面是 OneTalk PWA。
|
||||
2. `window.IcbuIM.IMBaaSSDK.default.getMessageService()` 存在,且暴露 `fetchMessagesWithoutUpdateToRead`、`sendImageMessage`、`sendTextMessage`、`sendUIMessages` 等方法。
|
||||
3. 历史读取产生 `code = 200` 的 JSON WebSocket 帧,消息列表位于 `body.userMessageModels`。
|
||||
4. 当前页面真实历史数据中出现:
|
||||
- 图片:`type = 1`、`subType = 60`、`msgType = 102`。
|
||||
- 附件:`type = 1`、`subType = 61`、`msgType = 10010`。
|
||||
5. 对同一帧运行仓库现有 `parseOneTalkMessages` 后,图片和附件仍保留在 `message.content`,但 `message.text` 为 `null`。
|
||||
6. 图片和附件的 `custom.data` 经 Base64 解码后是 JSON,而不是二进制图片或文件本体。
|
||||
|
||||
### 2.2 代码级确认、尚未做完整端到端实测
|
||||
|
||||
- Service Worker 的观察、IndexedDB 写入、Bright 上传和 Bright HTTP 返回均使用通用 JSON `content`,类型上没有把内容限制为文本。
|
||||
- 当前没有真实数据库写入后的 Mind 页面媒体渲染回归测试。
|
||||
- 没有执行真实图片上传、附件上传、发送确认和下载操作,因此不能把 SDK bundle 中的发送能力称为扩展已经支持的能力。
|
||||
|
||||
### 2.3 当前未确认
|
||||
|
||||
- 所有历史 `content.custom.type` 的完整枚举。
|
||||
- 图片/附件 URL 的长期有效期、签名参数和权限续期规则。
|
||||
- 群聊媒体消息是否使用完全相同的 `custom` 结构。
|
||||
- 视频、音频、表情、富文本卡片、商品卡片等其他非文本类型的完整字段契约。
|
||||
|
||||
## 3. OneTalk 消息的三层格式
|
||||
|
||||
### 3.1 外层 WebSocket JSON 帧
|
||||
|
||||
OneTalk WebSocket 返回的历史消息帧形态如下。字段值已抽象为类型,敏感值不展示:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"headers": {
|
||||
"mid": "<message-frame-id>",
|
||||
"sid": "<session-id>",
|
||||
"dt": "<frame-type>"
|
||||
},
|
||||
"body": {
|
||||
"degradeFailover": 0,
|
||||
"hasMore": 1,
|
||||
"nextCursor": 0,
|
||||
"userMessageModels": [
|
||||
{
|
||||
"message": {
|
||||
"cid": "<conversation-id>",
|
||||
"messageId": "<message-id>",
|
||||
"createAt": 0,
|
||||
"sender": {
|
||||
"uid": "<sender-id>"
|
||||
},
|
||||
"content": {
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 7,
|
||||
"data": "<base64-json>"
|
||||
}
|
||||
},
|
||||
"extension": {
|
||||
"basicMessageInfo": "<json-string>",
|
||||
"messageDisplayInfo": "<json-string>",
|
||||
"messageEventInfo": "<json-string>"
|
||||
},
|
||||
"searchableContent": {
|
||||
"summary": "<summary>"
|
||||
}
|
||||
},
|
||||
"readStatus": 0,
|
||||
"msgStatus": 1,
|
||||
"recallFeature": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
文本和媒体共享 `message` 外壳。不能通过外层 `type` 或 `messageType` 判断是否为图片;媒体识别必须进入 `message.content`。
|
||||
|
||||
### 3.2 历史同步的 MessagePack 推送
|
||||
|
||||
旧的同步推送帧还会把 `syncPushPackage.data[].data` 作为 Base64 字符串传输:
|
||||
|
||||
```text
|
||||
外层 JSON
|
||||
→ body.syncPushPackage.data[].data
|
||||
→ Base64 解码
|
||||
→ MessagePack 解码
|
||||
→ 数字键对象
|
||||
```
|
||||
|
||||
已验证的 `objectType = 40000` 消息结构包含以下数字路径(仅列出已经和明文 JSON 交叉确认的路径):
|
||||
|
||||
| MessagePack 路径 | 含义 |
|
||||
| ----------------------- | ----------------------------- |
|
||||
| `1.2` | 会话 ID `cid` |
|
||||
| `1.3` | 消息 ID |
|
||||
| `1.5` | 创建时间,Unix 毫秒 |
|
||||
| `1.6.1` | `contentType` |
|
||||
| `1.6.2.1` | 文本内容类型名,例如 `text` |
|
||||
| `1.6.2.2` | 内容类型对应的扩展对象 |
|
||||
| `1.7` | 读取状态 |
|
||||
| `1.8` | 消息读取设置 |
|
||||
| `1.9` | 展示样式 |
|
||||
| `1.10.basicMessageInfo` | JSON 字符串形式的基础消息扩展 |
|
||||
| `1.12` | 记录状态 |
|
||||
|
||||
MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位置推断图片、附件或其他媒体含义。媒体的业务字段名必须由明文 `userMessageModels`、SDK 归一化对象或协议定义交叉确认。
|
||||
|
||||
仓库已有的 [MessagePack 解码器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/sync-push-decoder.ts:1) 能解码数字、字符串、数组、对象、二进制、浮点数和 64 位整数;它目前是通用解码器,不负责把 `content.custom` 转成图片或附件领域对象。
|
||||
|
||||
## 4. 文本消息格式
|
||||
|
||||
文本样本的结构为:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 1,
|
||||
"text": {
|
||||
"content": "<text>",
|
||||
"extension": {
|
||||
"basicMessageInfo": "<json-string>",
|
||||
"messageDisplayInfo": "<json-string>",
|
||||
"messageEventInfo": "<json-string>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当前仓库将 `content` 整体保留,同时额外提取 `content.text.content` 到便利字段 `text`。这个便利字段只适用于真正带 `content.text.content` 的消息。
|
||||
|
||||
## 5. 图片消息格式
|
||||
|
||||
### 5.1 原始 WebSocket 内容
|
||||
|
||||
本次真实历史帧确认图片消息的原始内容形态为:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 7,
|
||||
"data": "<base64-json>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`custom.data` Base64 解码后是 JSON 对象,已确认字段形态如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"fileId": "<file-id>",
|
||||
"height": 0,
|
||||
"isOriginal": 0,
|
||||
"md5": "<md5>",
|
||||
"size": 0,
|
||||
"suffix": "<suffix>",
|
||||
"url": "<image-url>",
|
||||
"width": 0
|
||||
}
|
||||
```
|
||||
|
||||
这里的 `url` 是图片资源地址;报告不记录实际 URL,因为它可能包含访问签名或其他会话相关信息。`size`、尺寸、后缀和 MD5 是元数据,不是图片二进制本体。
|
||||
|
||||
### 5.2 页面 SDK 归一化形态
|
||||
|
||||
同一类消息经过 OneTalk 页面 SDK 归一化后,观察到:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"subType": 60,
|
||||
"msgType": 102,
|
||||
"viewType": 0,
|
||||
"messageType": "send",
|
||||
"content": "<serialized-content>",
|
||||
"originalData": {
|
||||
"fileId": "<file-id>",
|
||||
"height": 0,
|
||||
"isOriginal": 0,
|
||||
"md5": "<md5>",
|
||||
"size": 0,
|
||||
"suffix": "<suffix>",
|
||||
"url": "<image-url>",
|
||||
"width": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`subType = 60`、`msgType = 102` 是页面 SDK/渲染层的归类结果,不应替换原始 `contentType` 和 `custom.type`。同步事实应继续保留原始内容,归类字段只作为投影依据。
|
||||
|
||||
## 6. 附件消息格式
|
||||
|
||||
### 6.1 原始 WebSocket 内容
|
||||
|
||||
本次真实历史帧确认附件消息的原始内容形态为:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 10010,
|
||||
"data": "<base64-json>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`custom.data` Base64 解码后是一个卡片/文件描述对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"cardType": 0,
|
||||
"params": {
|
||||
"ctime": "<timestamp-string>",
|
||||
"downloadUrl": "<download-url-or-empty>",
|
||||
"extensionType": "<extension>",
|
||||
"id": "<file-id>",
|
||||
"md5": "<md5>",
|
||||
"name": "<file-name>",
|
||||
"parentId": "<parent-id>",
|
||||
"size": "<size-string>",
|
||||
"thumbnailUrl": "<thumbnail-url>",
|
||||
"type": "<file-type>",
|
||||
"url": "<file-url>",
|
||||
"version": "<version>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次样本中 `downloadUrl` 可以为空,而 `url` 和 `thumbnailUrl` 仍存在。因此附件展示逻辑不能简单地写成“只有 `downloadUrl` 非空才是有效附件”。应根据 `type`/`extensionType` 选择预览方式,并把下载地址缺失视为一个需要按页面 SDK 规则处理的状态。
|
||||
|
||||
### 6.2 页面 SDK 归一化形态
|
||||
|
||||
页面 SDK 对同一类消息的归类为:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"subType": 61,
|
||||
"msgType": 10010,
|
||||
"viewType": 0,
|
||||
"messageType": "send",
|
||||
"content": "<serialized-content>",
|
||||
"originalData": {
|
||||
"cardType": 0,
|
||||
"params": {
|
||||
"id": "<file-id>",
|
||||
"name": "<file-name>",
|
||||
"size": "<size-string>",
|
||||
"type": "<file-type>",
|
||||
"extensionType": "<extension>",
|
||||
"url": "<file-url>",
|
||||
"thumbnailUrl": "<thumbnail-url>",
|
||||
"downloadUrl": "<download-url-or-empty>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`subType = 61`、`msgType = 10010` 是页面显示/消息模型的归类,不是可以脱离 `custom.type = 10010` 单独使用的稳定事实键。
|
||||
|
||||
## 7. 当前仓库的数据流追踪
|
||||
|
||||
### 7.1 页面观察器
|
||||
|
||||
历史帧由 [历史解析器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts:12) 交给 `observedMessage()`。在 [消息模型](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:65) 中:
|
||||
|
||||
1. 只要 `message.content` 是对象,就把它作为完整 `content` 保留。
|
||||
2. 从 `content.contentType` 提取 `contentType`。
|
||||
3. 只有 `content.text.content` 是字符串时,才填充 `text`。
|
||||
4. 图片/附件没有 `content.text.content`,所以 `text` 为 `null`。
|
||||
|
||||
因此,当前代码并没有把图片/附件转换成错误的文本,也没有在这一层删除原始媒体对象。
|
||||
|
||||
### 7.2 页面桥与 Service Worker
|
||||
|
||||
[页面桥转换](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts:66) 会复制观察消息的所有 JSON 字段;如果原消息已有 `content`,不会用 `text` 覆盖它。`content` 进入 Service Worker 后仍然是通用 JSON 值。
|
||||
|
||||
### 7.3 Bright 服务与数据库
|
||||
|
||||
[Bright 归一化](/Users/ybf/code/trade-message-center-worktree/apps/server/src/onetalk/service.ts:170) 对 `content` 执行通用 JSON 校验和敏感键过滤,然后将清洗后的 `content` 持久化。当前公共契约中的 [OneTalkMessage](/Users/ybf/code/trade-message-center-worktree/apps/onetalk-contract/src/model.ts:203) 也将 `content` 定义为通用 `OneTalkJsonValue`,没有要求它必须含有 `text`。
|
||||
|
||||
所以现有事实链可以保存:
|
||||
|
||||
```text
|
||||
OneTalk raw content
|
||||
→ page observer content
|
||||
→ page bridge content
|
||||
→ Service Worker durable observation
|
||||
→ Bright content JSON
|
||||
→ Mind history response
|
||||
```
|
||||
|
||||
当前缺少的是在某个明确边界增加媒体语义投影,而不是重新设计这条事实链。
|
||||
|
||||
## 8. 已经可以做到什么
|
||||
|
||||
| 能力 | 当前状态 | 证据/限制 |
|
||||
| ----------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
|
||||
| 接收文本历史消息 | 已验证 | 现有观察器提取 `text` |
|
||||
| 接收图片历史消息 | 已验证 | CDP 实测 `custom.type=7`,现有 parser 保留 `content` |
|
||||
| 接收附件历史消息 | 已验证 | CDP 实测 `custom.type=10010`,现有 parser 保留 `content` |
|
||||
| 保留原始媒体元数据 | 代码已支持 | 通用 JSON `content` 贯穿页面桥、Service Worker、Bright |
|
||||
| 按 `channelAccountId + conversationId + messageId` 幂等保存 | 代码已支持 | 媒体不改变消息业务键 |
|
||||
| 在 Mind 历史接口返回原始媒体 JSON | 代码路径支持 | 尚未做真实 DB 写入和 Mind UI 回归 |
|
||||
| 将图片字段投影为 `imageUrl/width/height` | 当前未实现 | 需要新增共享内容解码器/投影器 |
|
||||
| 将附件字段投影为文件名、大小、预览和下载动作 | 当前未实现 | 需要处理 `downloadUrl` 为空的情况 |
|
||||
| 在 Mind 页面显示图片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 |
|
||||
| 在 Mind 页面显示附件卡片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 |
|
||||
| OneTalk 文本发送 | 已有路径 | `sendUIMessages` 与文本确认逻辑以字符串正文为中心 |
|
||||
| OneTalk 图片发送 | 页面 SDK 有方法 | bundle 观察到 `sendImageMessage({ cid, picUrl })`;扩展未接入完整发送契约 |
|
||||
| OneTalk 本地文件/附件发送 | 页面 bundle 有上传流程 | 涉及 `prepareSendFileWithGroup`、OSS 上传和文件卡片;未做真实上传验证 |
|
||||
| 图片/附件发送确认 | 当前未实现 | 出站确认关联器只按文本内容匹配 |
|
||||
| 下载二进制到 Bright | 当前未实现 | 当前只保存消息 JSON,不保存媒体本体 |
|
||||
| 群聊图片/附件全量同步 | 当前未确认 | 既有历史同步对群聊会话有跳过/不支持边界 |
|
||||
|
||||
## 9. 发送侧调查结果
|
||||
|
||||
### 9.1 图片发送
|
||||
|
||||
当前页面 SDK 原型暴露:
|
||||
|
||||
```text
|
||||
sendImageMessage(input)
|
||||
```
|
||||
|
||||
已加载的 `im-weblite-chat` bundle 中观察到 Native PC 分支调用形态:
|
||||
|
||||
```js
|
||||
messageService.sendImageMessage({
|
||||
cid: conversationId,
|
||||
picUrl: selectedPicture,
|
||||
});
|
||||
```
|
||||
|
||||
这说明 OneTalk 页面具有图片发送入口,但这不等于扩展已经具备图片发送能力。扩展当前页面命令在 [page-command.ts](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts:97) 中要求 `command.content` 是字符串,并交给文本型 `sendUIMessages`;需要新增图片输入契约、SDK 调用和发送确认规则后才能接入。
|
||||
|
||||
### 9.2 文件/附件发送
|
||||
|
||||
bundle 中观察到的文件流程不是“直接把本地文件放进消息 JSON”,而是:
|
||||
|
||||
```text
|
||||
File
|
||||
→ 文件类型/大小判断
|
||||
→ 图片或视频压缩(适用时)
|
||||
→ OSS/云盘上传
|
||||
→ prepareSendFileWithGroup 建立文件关系
|
||||
→ 构造文件卡片消息
|
||||
→ sendMessage / sendUIMessages
|
||||
```
|
||||
|
||||
文件卡片消息中出现过以下业务字段:
|
||||
|
||||
```text
|
||||
fileId
|
||||
fileCardUrl
|
||||
downloadUrl
|
||||
previewUrl
|
||||
fileName / name
|
||||
fileSize / size
|
||||
fileType / materialType
|
||||
md5
|
||||
nodeName
|
||||
```
|
||||
|
||||
这些字段来自页面上传流程的内部模型,不能直接假设它们和历史 `custom.data.params` 的字段名一一相同。发送侧必须先抓取一次真实、用户明确触发的上传网络流程,再固定契约。
|
||||
|
||||
### 9.3 发送确认限制
|
||||
|
||||
当前 [send-observation.ts](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:11) 的待确认状态以字符串 `content` 和消息时间窗口进行关联。对于图片/附件:
|
||||
|
||||
- 图片通常没有可比较的文本正文。
|
||||
- 附件卡片的 HTML/URL 可能在发送前后发生变化。
|
||||
- `sendImageMessage` 或文件上传回调返回的操作 ID不能直接当作最终消息 ID,必须等待完整的 sent-direction OneTalk 观察消息。
|
||||
|
||||
因此图片/附件发送确认不能简单复用“比较 `text`”的逻辑,也不能仅凭 SDK Promise resolve 就写入 Bright。
|
||||
|
||||
## 10. 推荐的实现边界
|
||||
|
||||
如果后续开始实现,建议保持原始事实与展示投影分离:
|
||||
|
||||
### 10.1 共享内容解码器
|
||||
|
||||
在 `apps/onetalk-contract` 或其最近共同父目录增加一个唯一的内容投影器,输入原始 `content`,输出判别联合:
|
||||
|
||||
```text
|
||||
text
|
||||
image
|
||||
attachment
|
||||
unknown
|
||||
```
|
||||
|
||||
最小识别规则:
|
||||
|
||||
```text
|
||||
content.contentType === 1 && content.text.content 是字符串 → text
|
||||
content.contentType === 101 && content.custom.type === 7 → image
|
||||
content.contentType === 101 && content.custom.type === 10010 → attachment
|
||||
其他 → unknown
|
||||
```
|
||||
|
||||
`custom.data` 必须经过:
|
||||
|
||||
```text
|
||||
Base64 解码 → UTF-8 → JSON.parse → 字段校验
|
||||
```
|
||||
|
||||
不能只看 `contentType=101`,因为它至少同时承载图片和附件。
|
||||
|
||||
### 10.2 原始内容必须继续保留
|
||||
|
||||
投影结果不应替换原始 `content`。推荐消息同时保留:
|
||||
|
||||
```text
|
||||
content 原始 OneTalk JSON,可用于审计、未知类型和未来兼容
|
||||
contentType 原始数字类型
|
||||
media 经过严格校验的可选语义投影
|
||||
text 仅文本便利字段
|
||||
```
|
||||
|
||||
未知类型进入 `unknown`,并保留原始 JSON;不能为了让 UI 正常而把未知媒体伪装成文本。
|
||||
|
||||
### 10.3 URL 与二进制边界
|
||||
|
||||
- 首发只保存消息元数据和页面提供的 URL,不把图片/文件二进制下载到 Bright。
|
||||
- 向 Mind 转发时使用字段白名单,不直接序列化整段 OneTalk 原始对象。
|
||||
- 需要确认 URL 是否带签名、有效期多久、是否需要 Cookie;这些信息本次没有读取,不能预设“永久公开 URL”。
|
||||
- `downloadUrl` 为空时,应明确返回“可预览但不可直接下载”或由页面 SDK 提供刷新动作,不能静默拼接 URL。
|
||||
|
||||
### 10.4 发送侧单独建模
|
||||
|
||||
图片发送和附件发送应分别建模,不把文件上传过程塞进文本 `content` 字段:
|
||||
|
||||
```text
|
||||
send.text
|
||||
send.image
|
||||
send.file
|
||||
```
|
||||
|
||||
每一种发送都必须继续遵循现有三态结果:
|
||||
|
||||
```text
|
||||
confirmed_sent
|
||||
rejected_before_send
|
||||
delivery_unknown
|
||||
```
|
||||
|
||||
只有观察到完整的 sent-direction 消息事实后,才能产生 `confirmed_sent`。
|
||||
|
||||
## 11. 调查中没有做的事情
|
||||
|
||||
为避免对真实账号产生副作用,本次没有:
|
||||
|
||||
- 点击图片发送、文件选择器、上传或下载按钮。
|
||||
- 修改 OneTalk 页面状态、IndexedDB、Cookie、Local Storage 或 Service Worker 数据。
|
||||
- 把任何真实 token、账号加密值、会话 SID、消息正文或完整 URL 写入报告。
|
||||
- 把 SDK bundle 中的内部函数名直接当成稳定公共 API。
|
||||
- 声称图片/附件发送已经端到端成功。
|
||||
- 创建 Trellis task 或修改业务代码。
|
||||
|
||||
## 12. 结论
|
||||
|
||||
当前同步系统已经具备“接收并保存图片/附件原始消息”的基础条件,真正缺口集中在三处:
|
||||
|
||||
1. 将 `content.custom.data` 从 Base64 JSON 解码为经过校验的图片/附件语义对象。
|
||||
2. 在 Bright → Mind 的边界定义媒体字段白名单和 URL 生命周期处理。
|
||||
3. 为图片/附件发送建立独立输入和基于 sent-direction 事实的确认关联。
|
||||
|
||||
因此不需要重写 OneTalk WebSocket、MessagePack 解码器、会话锚点或消息幂等机制。下一次实现应从共享内容投影器和测试样本开始,并把真实上传/发送抓包作为单独的运行时验证步骤。
|
||||
@@ -0,0 +1,220 @@
|
||||
# OneTalk 媒体消息格式二次运行态复核方案
|
||||
|
||||
> 日期:2026-09-02
|
||||
> 性质:方案讨论与只读运行态复核清单,不是 Trellis task,不包含实现承诺
|
||||
> 目标:通过真实运行态证据复核两份调查文档的共同结论、冲突结论和仍不明确的内容
|
||||
|
||||
## 1. 复核对象
|
||||
|
||||
本次复核以以下两份文档为输入:
|
||||
|
||||
1. `/Users/ybf/code/trade-message-center-worktree/docs/onetalk-media-message-format-investigation.md`
|
||||
2. `/Users/ybf/code/trade-message-center-worktree/docs/onetalk-message-content-formats.md`
|
||||
|
||||
复核时必须区分三类结论:
|
||||
|
||||
- **运行态事实**:能够从真实 WebSocket、SDK 返回、页面桥、IndexedDB、Bright 或 Mind 中直接观察。
|
||||
- **代码事实**:能够从当前代码路径确认,但尚未完成真实端到端验证。
|
||||
- **方案决策**:例如 raw content 是否保留、normalized content 放在哪一层。这类结论不能伪装成运行态事实。
|
||||
|
||||
## 2. 安全边界与停止条件
|
||||
|
||||
### 2.1 默认只读
|
||||
|
||||
默认允许:
|
||||
|
||||
- 读取当前 Chromium/CDP 目标、版本和已加载 OneTalk SDK 方法。
|
||||
- 调用 `getConversationListByPagination`。
|
||||
- 调用 `fetchMessagesWithoutUpdateToRead`。
|
||||
- 观察历史 WebSocket 响应和同一次调用的 SDK 归一化结果。
|
||||
- 读取扩展 IndexedDB、Service Worker 状态和 Bright 的只读查询结果。
|
||||
- 在内存中执行 Base64、UTF-8 和 JSON 解码,并只输出字段名、类型、长度和布尔判断。
|
||||
|
||||
默认禁止:
|
||||
|
||||
- 调用 `fetchMessages`、`updateMessageToRead` 或其它会改变已读状态的方法。
|
||||
- 点击发送、上传、下载、文件选择器或会改变页面 store 的操作。
|
||||
- 将 Cookie、SID、token、`chatToken`、加密账号、消息正文、完整 URL、URL query 值或原始 payload 写入文件、日志或报告。
|
||||
- 为了便于复核而修改生产数据、IndexedDB、Local Storage、Service Worker 数据或 Bright 数据库。
|
||||
|
||||
图片/文件发送、真实上传、二进制下载和主动制造 live push 必须作为单独步骤,由用户明确同意后再执行;它们不属于默认只读复核范围。
|
||||
|
||||
### 2.2 立即停止
|
||||
|
||||
出现以下任一情况时停止当前检查并清除未保存的临时输出:
|
||||
|
||||
- DevTools、终端或脚本准备输出认证值、完整 URL 或消息正文。
|
||||
- 所调用的方法实际改变已读状态、会话状态或页面数据。
|
||||
- URL 验证开始返回二进制正文,而当前步骤只获准检查响应元数据。
|
||||
- 无法区分测试账号、真实业务账号或目标会话。
|
||||
|
||||
## 3. 实际调试复核范围
|
||||
|
||||
### 3.1 环境基线
|
||||
|
||||
| 编号 | 检查项 | 需要记录的安全证据 | 完成条件 |
|
||||
| ---- | --------------- | ---------------------------------------------------------- | ------------------------------ |
|
||||
| R01 | Chromium 与 CDP | 浏览器版本、CDP protocol 版本、目标页面 URL 的 origin/path | 能确认本次检查对应 OneTalk PWA |
|
||||
| R02 | 扩展与代码版本 | 扩展版本、仓库 commit、工作树是否有影响复核的未提交改动 | 复核结果能够绑定到唯一代码快照 |
|
||||
| R03 | SDK 方法 | 方法是否存在及函数类型,不记录函数闭包或运行时凭证 | 确认只读历史方法可用 |
|
||||
| R04 | 数据状态 | IndexedDB schema 版本及各表计数,不输出记录正文 | 能区分本次复核前后的已有数据 |
|
||||
|
||||
### 3.2 历史消息原始格式
|
||||
|
||||
至少选择包含下列样本的历史会话;每个样本只使用 `T1`、`I1`、`F1`、`C1` 等本地别名,不在报告中记录真实会话 ID 或消息 ID。
|
||||
|
||||
| 编号 | 样本 | 必须检查 | 不得记录 |
|
||||
| ---- | --------------- | -------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| R10 | 文本 `T1` | `contentType`、`text.content` 类型、`text.extension` 的键名 | 正文、extension 字符串值 |
|
||||
| R11 | 图片 `I1` | `contentType`、`custom.type`、Base64/UTF-8/JSON 是否成立、解码后字段名和字段类型 | MD5、fileId、完整 URL 和 query 值 |
|
||||
| R12 | PDF 文件 `F1` | `custom.type`、`cardType`、`params` 字段名和类型 | 文件名原值、文件 ID、完整 URL |
|
||||
| R13 | 非文件卡片 `C1` | `custom.type`、`cardType`、SDK `subType/msgType` | 卡片业务值和账号信息 |
|
||||
|
||||
每个样本都需要同时保留两种独立、脱敏的证据:
|
||||
|
||||
1. WebSocket 原始 `body.userMessageModels[].message.content` 的结构证据。
|
||||
2. 同一次请求对应的 SDK `list[]` 归一化类型和 `originalData` 字段结构。
|
||||
|
||||
不能只根据 SDK 的 `msgType/subType` 反推 WebSocket 原始类型,也不能只根据 WebSocket 的 `custom.type` 推断业务含义。
|
||||
|
||||
## 4. 冲突结论的第二次确认
|
||||
|
||||
每项冲突必须得到两份相互独立的证据后才能定案。代码引用和同一份运行输出的重复截图不算两份独立证据。
|
||||
|
||||
### C1. `custom.type=10010` 是否等于附件
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 真实 PDF 样本:`custom.type=10010`,解码后 `cardType=12`,SDK 归一化为文件类型。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 真实非文件卡片:同样为 `custom.type=10010`,但解码后 `cardType=2000` 或其它非文件值,且 SDK 不把它归一化为文件。
|
||||
|
||||
**定案条件**
|
||||
|
||||
```text
|
||||
file = contentType=101
|
||||
&& custom.type=10010
|
||||
&& decoded.cardType=12
|
||||
&& decoded.params 通过文件 schema
|
||||
```
|
||||
|
||||
若缺少非文件卡片样本,只能说“当前 PDF 样本是附件”,不能泛化为“所有 `custom.type=10010` 都是附件”。
|
||||
|
||||
### C2. 附件样本的 `cardType` 是 `0` 还是 `12`
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 直接读取 PDF WebSocket 原始 `custom.data` 的解码结果,只记录 `cardType` 数字。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 对照同一条消息的 SDK `originalData.cardType`。
|
||||
|
||||
**定案条件**
|
||||
|
||||
- 两个边界均为 `12`:文档中的 `0` 必须标记为错误或不当占位。
|
||||
- 两个边界不一致:记录为 SDK 转换差异,不能先选择其中一个。
|
||||
|
||||
`cardType` 是判别字段,文档示例不得用 `0` 冒充脱敏占位;未知值应写成 `<number>`。
|
||||
|
||||
### C3. raw content 应继续保留还是在 MAIN world 归一化
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 当前观察器确实复制完整 raw content,Bright 当前清洗器确实对字符串原样放行。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 真实运行链检查证明序列化 JSON 或 Base64 中的敏感键名能够到达页面桥、IndexedDB、Bright 输入或持久化边界中的至少一处。
|
||||
|
||||
**定案条件**
|
||||
|
||||
- 若敏感内容能跨边界:raw content 不得继续作为跨层合同,必须在 MAIN world 白名单解码。
|
||||
- 即使当前样本未穿透,也不能据此证明任意 raw content 安全;还需要负向 fixture 覆盖字符串和 Base64 嵌套敏感键。
|
||||
|
||||
建议的目标不变量是:
|
||||
|
||||
```text
|
||||
raw content 仅在 MAIN world 短暂存在
|
||||
→ 输出 text | image | file | unsupported
|
||||
→ 下游只校验 normalized contract,不再解析 OneTalk raw payload
|
||||
```
|
||||
|
||||
### C4. `content` 是 raw 事实源,还是 normalized 事实源
|
||||
|
||||
这不是单靠抓包能够决定的事实冲突,而是架构决策。运行态复核只需要确认现有消费者分别读取了什么;方案确认需要满足:
|
||||
|
||||
- 跨层只有一个可写内容事实源。
|
||||
- 顶层 `message.text` 如需兼容,只能从 `content.kind === "text"` 派生。
|
||||
- 有效但暂未支持的 card 使用 `kind="unsupported"`,不伪装成文本或文件。
|
||||
- 非法 Base64、非法 JSON、字段错误和超限数据进入明确 anomaly 或受控失败,不静默变成空文本。
|
||||
- 合同含义从 raw 改为 normalized 时必须提升协议版本,不能让同一协议版本同时承载两种语义。
|
||||
|
||||
### C5. 完整 `sourceUrl` 是否可以持久化和发送给 Mind
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 解析图片 `url`、文件 `url/downloadUrl/thumbnailUrl`,只记录 scheme、host、path 后缀、query 键名和是否为空,不记录 query 值。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 分别验证 OneTalk 登录上下文和无登录上下文中的响应状态、跳转次数、最终 host、Cookie 依赖和 CORS;只记录状态与布尔结果。
|
||||
|
||||
**定案条件**
|
||||
|
||||
- 未确认 URL 生命周期、授权依赖和 query 敏感性前,`sourceUrl` 不能同时被定义为必填字段并承诺跨网络/数据库持久化。
|
||||
- 如果 URL query 含短期授权或身份值,第一阶段只同步安全元数据和资源 ID;是否增加临时 URL 换取或受控代理另行决策。
|
||||
- 如果需要实际 GET、重定向跟随或读取二进制,本项必须先获得用户明确同意;默认只允许检查响应元数据。
|
||||
|
||||
## 5. 不明确内容的再次确认
|
||||
|
||||
| 编号 | 当前不明确内容 | 再确认方法 | 定案所需证据 |
|
||||
| ---- | -------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------ |
|
||||
| U01 | 图片/文件 URL 是否依赖 OneTalk Cookie | 登录上下文与干净上下文分别检查响应元数据 | 两种上下文的状态、跳转和 Cookie 依赖结论 |
|
||||
| U02 | URL 有效期 | 同一脱敏样本在不同时间点检查响应元数据,不记录 URL | 至少能观察有效、过期或续期行为中的一种明确规则 |
|
||||
| U03 | URL 能否跨 Origin 给 Mind 使用 | 在 Mind 所在 Origin 做只读请求或实际 `<img>`/链接测试 | CORS、Cookie、跳转和 CSP 的真实结果 |
|
||||
| U04 | `downloadUrl` 为空时 `params.url` 的真实语义 | 页面行为与 SDK/bundle 调用点交叉确认 | 能区分预览、下载、操作入口或临时跳转 |
|
||||
| U05 | TXT 是否与 PDF 同构 | 获取一条真实入站 TXT 历史样本并按 R12 检查 | `cardType`、params schema 和 SDK 归一化结果 |
|
||||
| U06 | live 图片/文件 push 是否与历史同构 | 观察真实入站 live 媒体消息,不主动发送 | live envelope 与 history content 的字段级比较 |
|
||||
| U07 | 群聊媒体是否同构 | 在明确允许的群聊样本上执行只读历史检查 | 群聊 raw content、身份和 participant 结构 |
|
||||
| U08 | `custom.type`/`cardType` 完整枚举 | 对已有历史样本做类型和 cardType 计数,只输出数字集合 | 至少确认当前账号样本范围,不宣称全局完整枚举 |
|
||||
| U09 | `confirmed` 是否等于真实持久化 | 将样本 ACK 与 Bright 数据库行只读对应 | `accepted/duplicate` 与数据库事实的区别 |
|
||||
| U10 | Mind 是否能显示媒体 | 检查真实 history/event 消费和 UI 模型 | 真实渲染结果,不以服务启动或 JSON 可返回代替 |
|
||||
| U11 | MessagePack live/sync push 的媒体路径 | 捕获真实媒体 MessagePack 样本并与明文历史消息交叉对应 | 数字路径和业务字段一一对应,不靠数组位置猜测 |
|
||||
| U12 | 图片/文件发送和确认 | 仅在用户明确授权后执行一次受控发送 | 上传结果、最终 sent-direction 消息和三态发送结果 |
|
||||
|
||||
U01–U04、U06、U07 和 U12 依赖特定运行环境或真实样本;缺少样本时状态必须写为 `blocked_by_sample`,不能写成已验证或默认同构。
|
||||
|
||||
## 6. 复核输出格式
|
||||
|
||||
每个检查项使用以下固定格式记录:
|
||||
|
||||
```md
|
||||
### Rxx / Cx / Uxx:标题
|
||||
|
||||
- 状态:verified | contradicted | not_verified | blocked_by_sample | blocked_by_environment
|
||||
- 环境:浏览器版本、扩展版本、commit
|
||||
- 样本别名:T1 / I1 / F1 / C1
|
||||
- 证据 A:来源边界、字段名、字段类型或布尔结果
|
||||
- 证据 B:独立来源边界、字段名、字段类型或布尔结果
|
||||
- 结论:只描述证据能够支持的范围
|
||||
- 未覆盖:明确列出不能外推的消息类型或场景
|
||||
```
|
||||
|
||||
最终报告必须分别列出:
|
||||
|
||||
1. 两份文档共同成立的结论。
|
||||
2. 经两次独立证据确认的冲突及采用哪一侧。
|
||||
3. 被新证据推翻的原结论。
|
||||
4. 尚未确认、缺样本或缺环境的内容。
|
||||
5. 纯方案决策,不得写成运行态事实的内容。
|
||||
|
||||
## 7. 本轮不做
|
||||
|
||||
- 不修改两份原调查文档。
|
||||
- 不创建 Trellis task、PRD 或实现计划。
|
||||
- 不修改协议、数据库、扩展、Bright 或 Mind 代码。
|
||||
- 不执行图片/文件发送、上传或二进制下载。
|
||||
- 不以静态代码检查代替真实运行态结论。
|
||||
@@ -0,0 +1,432 @@
|
||||
# OneTalk 图片与附件消息同步 PRD
|
||||
|
||||
> 日期:2026-09-02
|
||||
> 状态:Draft
|
||||
> 性质:独立 PRD,不创建 Trellis task,不包含实现授权
|
||||
|
||||
## 1. 背景
|
||||
|
||||
OneTalk 历史消息已经包含图片和附件,但当前跨层合同缺少稳定的媒体语义。消费端不能可靠判断一条消息是图片、附件还是其它业务卡片,也不能确定哪个 URL 用于预览或下载。
|
||||
|
||||
本 PRD 固定三个问题:
|
||||
|
||||
1. 图片和附件从哪里获取。
|
||||
2. 解码后返回什么 TypeScript 类型。
|
||||
3. 如何从 OneTalk 原始消息中安全取得并规范化数据。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 在 OneTalk MAIN world 内完成 Base64、UTF-8、JSON 解码和字段校验。
|
||||
- 将图片和附件转换为唯一的 typed content,不让下游继续解析 OneTalk raw payload。
|
||||
- 明确图片预览地址、附件预览地址和附件下载地址的选择规则。
|
||||
- 将合法但暂未支持的业务卡片返回为 `unsupported`,不伪装成附件或文本。
|
||||
- 下载地址缺失时返回 `null`,不拼接、修改或猜造 URL。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不下载图片或附件二进制。
|
||||
- 不验证 URL 在无登录环境、Mind Origin 或长期存储后的可用性。
|
||||
- 不实现图片或附件发送、上传和发送确认。
|
||||
- 不依赖 `window.__conversationListFullData__`、`msgCache` 等调试全局作为生产合同。
|
||||
- 不把 `activeAccountId` 当作 `conversationCode`。
|
||||
|
||||
## 4. 已确认的运行态事实
|
||||
|
||||
| 类型 | WebSocket 原始判定 | SDK 归一化判定 | 当前样本 |
|
||||
| ---------- | ------------------------------------------------------ | -------------------------------------------- | -------- |
|
||||
| 图片 | `contentType=101`、`custom.type=7` | `msgType=102`、`subType=60` | JPEG |
|
||||
| 文件附件 | `contentType=101`、`custom.type=10010`、`cardType=12` | `msgType=10010`、`subType=61`、`cardType=12` | ZIP、PDF |
|
||||
| 非文件卡片 | `contentType=101`、`custom.type=10010`、`cardType!=12` | 当前样本为 `msgType=10010`、`subType=2000` | 业务卡片 |
|
||||
|
||||
补充事实:
|
||||
|
||||
- 图片 `originalData.url` 的 `fileAction=imagePreview`。
|
||||
- ZIP 附件 `params.url` 的 `fileAction=download`。
|
||||
- PDF 附件 `params.url` 的 `fileAction=officePreview`。
|
||||
- 两条附件的 `params.downloadUrl` 都是空字符串。
|
||||
- `thumbnailUrl` 是缩略图操作地址,不是下载地址。
|
||||
- 顶层 SDK `message.content` 是字符串,但不等于图片 URL 或附件 `params.url`,不能作为媒体资源地址。
|
||||
|
||||
## 5. 获取路径
|
||||
|
||||
### 5.1 会话身份
|
||||
|
||||
```text
|
||||
URL activeAccountId
|
||||
→ 只用于定位当前选中的买家/会话对象
|
||||
→ 从会话对象读取 conversation.cid
|
||||
→ conversation.cid 作为 conversationCode
|
||||
```
|
||||
|
||||
必须满足:
|
||||
|
||||
- `activeAccountId` 不能直接作为 `conversationCode`。
|
||||
- 找不到唯一会话对象或缺少 `cid` 时立即失败,不回退到 URL、当前页面猜测值或其它会话。
|
||||
- 每次历史请求都携带明确的 `conversationCode: conversation.cid`。
|
||||
|
||||
### 5.2 历史消息入口
|
||||
|
||||
当前 Chromium 运行态的 SDK 调用路径是:
|
||||
|
||||
```ts
|
||||
window._imsdk.getMessageService().listMessageWithConversationCodeForHistory({
|
||||
conversationCode: conversation.cid,
|
||||
cursor,
|
||||
count,
|
||||
});
|
||||
```
|
||||
|
||||
该方法是异步触发型入口,直接返回值为 `undefined`。生产适配器不得把函数返回值当作消息列表,而必须等待与本次 `requestId + conversationCode` 对应的历史完成事件或 WebSocket 响应。
|
||||
|
||||
底层已观察到的 WebSocket 方法为:
|
||||
|
||||
```text
|
||||
/r/MessageManager/listUserMessages
|
||||
```
|
||||
|
||||
### 5.3 原始数据路径
|
||||
|
||||
媒体解码器的生产输入来自同次历史响应:
|
||||
|
||||
```text
|
||||
WebSocket response
|
||||
→ body.userMessageModels[]
|
||||
→ item.message
|
||||
→ item.message.content
|
||||
```
|
||||
|
||||
字段路径:
|
||||
|
||||
```text
|
||||
图片:
|
||||
message.content.contentType
|
||||
message.content.custom.type
|
||||
message.content.custom.data
|
||||
|
||||
附件:
|
||||
message.content.contentType
|
||||
message.content.custom.type
|
||||
message.content.custom.data
|
||||
→ Base64 decode
|
||||
→ UTF-8 decode
|
||||
→ JSON.parse
|
||||
→ cardType / params
|
||||
```
|
||||
|
||||
### 5.4 SDK 交叉校验路径
|
||||
|
||||
SDK 归一化对象只用于同条消息的类型和字段交叉校验:
|
||||
|
||||
```text
|
||||
图片:
|
||||
sdkMessage.msgType
|
||||
sdkMessage.subType
|
||||
sdkMessage.originalData
|
||||
|
||||
附件:
|
||||
sdkMessage.msgType
|
||||
sdkMessage.subType
|
||||
sdkMessage.originalData.cardType
|
||||
sdkMessage.originalData.params
|
||||
```
|
||||
|
||||
生产实现不得只根据 `msgType=10010` 判断附件,因为相同 `msgType` 还包含 `cardType=2000` 的非文件卡片。
|
||||
|
||||
## 6. 返回数据类型
|
||||
|
||||
```ts
|
||||
type OneTalkUrlScope = "onetalk_session";
|
||||
|
||||
type OneTalkImageContent = {
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5: string | null;
|
||||
previewUrl: string;
|
||||
downloadUrl: null;
|
||||
urlScope: OneTalkUrlScope;
|
||||
};
|
||||
|
||||
type OneTalkFileContent = {
|
||||
kind: "file";
|
||||
fileId: string;
|
||||
parentId: string;
|
||||
fileName: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
md5: string | null;
|
||||
previewUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
downloadUrl: string | null;
|
||||
downloadState: "available" | "not_provided";
|
||||
urlScope: OneTalkUrlScope;
|
||||
};
|
||||
|
||||
type OneTalkUnsupportedContent = {
|
||||
kind: "unsupported";
|
||||
sourceContentType: number;
|
||||
sourceCustomType: number | null;
|
||||
cardType: number | null;
|
||||
reason: "unsupported_card";
|
||||
};
|
||||
|
||||
type OneTalkNormalizedMediaContent =
|
||||
OneTalkImageContent | OneTalkFileContent | OneTalkUnsupportedContent;
|
||||
|
||||
type OneTalkMediaDecodeResult =
|
||||
| {
|
||||
status: "decoded";
|
||||
content: OneTalkNormalizedMediaContent;
|
||||
}
|
||||
| {
|
||||
status: "invalid";
|
||||
reason:
|
||||
| "invalid_base64"
|
||||
| "invalid_utf8"
|
||||
| "invalid_json"
|
||||
| "invalid_schema"
|
||||
| "payload_too_large";
|
||||
};
|
||||
```
|
||||
|
||||
合同约束:
|
||||
|
||||
- URL 字段统一返回 `string | null`,不使用空字符串表达缺失。
|
||||
- URL 是 OneTalk 登录上下文中的临时操作地址,`urlScope` 固定为 `onetalk_session`。
|
||||
- 第一阶段不得承诺这些 URL 能在 Mind、无 Cookie 环境或长期持久化后直接访问。
|
||||
- `content` 是唯一事实源;兼容字段 `message.text` 只能由 `content.kind === "text"` 派生。
|
||||
|
||||
## 7. 怎么取图片数据
|
||||
|
||||
### 7.1 判定
|
||||
|
||||
```ts
|
||||
content.contentType === 101 && content.custom.type === 7;
|
||||
```
|
||||
|
||||
SDK 交叉校验:
|
||||
|
||||
```ts
|
||||
sdkMessage.msgType === 102 && sdkMessage.subType === 60;
|
||||
```
|
||||
|
||||
### 7.2 解码路径
|
||||
|
||||
```text
|
||||
content.custom.data
|
||||
→ 校验 Base64 字符串和最大长度
|
||||
→ Base64 解码为 bytes
|
||||
→ TextDecoder("utf-8", { fatal: true })
|
||||
→ JSON.parse
|
||||
→ 校验图片 schema
|
||||
```
|
||||
|
||||
解码后的字段:
|
||||
|
||||
```ts
|
||||
type OneTalkImagePayload = {
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: 0 | 1;
|
||||
md5: string;
|
||||
url: string;
|
||||
};
|
||||
```
|
||||
|
||||
映射规则:
|
||||
|
||||
```ts
|
||||
return {
|
||||
kind: "image",
|
||||
fileId: payload.fileId,
|
||||
extension: payload.suffix.toLowerCase(),
|
||||
sizeBytes: payload.size,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
isOriginal: payload.isOriginal === 1,
|
||||
md5: payload.md5 || null,
|
||||
previewUrl: payload.url,
|
||||
downloadUrl: null,
|
||||
urlScope: "onetalk_session",
|
||||
};
|
||||
```
|
||||
|
||||
图片 URL 必须是绝对 HTTPS URL,当前允许的运行态 host 为 `clouddisk.alibaba.com`,当前动作是 `fileAction=imagePreview`。
|
||||
|
||||
## 8. 怎么取附件数据
|
||||
|
||||
### 8.1 判定
|
||||
|
||||
```ts
|
||||
content.contentType === 101 && content.custom.type === 10010 && decoded.cardType === 12;
|
||||
```
|
||||
|
||||
SDK 交叉校验:
|
||||
|
||||
```ts
|
||||
sdkMessage.msgType === 10010 &&
|
||||
sdkMessage.subType === 61 &&
|
||||
sdkMessage.originalData.cardType === 12;
|
||||
```
|
||||
|
||||
### 8.2 解码后的字段
|
||||
|
||||
```ts
|
||||
type OneTalkFilePayload = {
|
||||
cardType: 12;
|
||||
params: {
|
||||
type: string;
|
||||
ctime: string;
|
||||
version: string;
|
||||
extensionType: string;
|
||||
id: string;
|
||||
parentId: string;
|
||||
md5: string;
|
||||
name: string;
|
||||
size: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 8.3 文件字段映射
|
||||
|
||||
```text
|
||||
fileId ← params.id
|
||||
parentId ← params.parentId
|
||||
fileName ← params.name
|
||||
extension ← params.extensionType
|
||||
sizeBytes ← 十进制解析 params.size
|
||||
md5 ← params.md5 或 null
|
||||
thumbnailUrl ← 非空 params.thumbnailUrl,否则 null
|
||||
```
|
||||
|
||||
必须校验:
|
||||
|
||||
- `params.name` 的后缀和 `params.extensionType` 一致。
|
||||
- `params.size` 是十进制非负整数字符串,转换后是安全整数。
|
||||
- 所有非空 URL 都是绝对 HTTPS URL并通过 host allowlist。
|
||||
- 文件名只能作为文本展示,不能作为 HTML。
|
||||
|
||||
## 9. 附件预览与下载地址规则
|
||||
|
||||
先解析 URL 的 `fileAction`,但不得修改原 URL。
|
||||
|
||||
```ts
|
||||
function selectFileAccessUrls(params: OneTalkFilePayload["params"]): {
|
||||
previewUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
downloadUrl: string | null;
|
||||
downloadState: "available" | "not_provided";
|
||||
} {
|
||||
const sourceAction = readValidatedFileAction(params.url);
|
||||
const explicitDownloadUrl = normalizeOptionalHttpsUrl(params.downloadUrl);
|
||||
|
||||
const downloadUrl = explicitDownloadUrl ?? (sourceAction === "download" ? params.url : null);
|
||||
|
||||
const previewUrl = sourceAction === "officePreview" ? params.url : null;
|
||||
|
||||
return {
|
||||
previewUrl,
|
||||
thumbnailUrl: normalizeOptionalHttpsUrl(params.thumbnailUrl),
|
||||
downloadUrl,
|
||||
downloadState: downloadUrl ? "available" : "not_provided",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
确定规则:
|
||||
|
||||
- `params.downloadUrl` 非空且通过 URL 校验时,优先作为下载地址。
|
||||
- `params.downloadUrl` 为空且 `params.url.fileAction === "download"` 时,使用 `params.url`。
|
||||
- `params.url.fileAction === "officePreview"` 时,它只是预览地址,`downloadUrl` 返回 `null`。
|
||||
- `thumbnailUrl` 永远不能作为下载地址。
|
||||
- 不允许把 `officePreview`、`imagePreview` 改成 `download` 来猜造下载链接。
|
||||
|
||||
当前样本结果:
|
||||
|
||||
| 文件类型 | `params.url.fileAction` | `params.downloadUrl` | 返回 `downloadUrl` |
|
||||
| -------- | ----------------------- | -------------------- | ------------------ |
|
||||
| ZIP | `download` | 空 | `params.url` |
|
||||
| PDF | `officePreview` | 空 | `null` |
|
||||
|
||||
## 10. 非文件卡片
|
||||
|
||||
以下内容不是附件:
|
||||
|
||||
```ts
|
||||
content.contentType === 101 && content.custom.type === 10010 && decoded.cardType !== 12;
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```ts
|
||||
{
|
||||
status: "decoded",
|
||||
content: {
|
||||
kind: "unsupported",
|
||||
sourceContentType: 101,
|
||||
sourceCustomType: 10010,
|
||||
cardType: decoded.cardType,
|
||||
reason: "unsupported_card",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
不得返回 `file`,也不得降级为空文本。
|
||||
|
||||
## 11. 数据流
|
||||
|
||||
```text
|
||||
Service Worker history request
|
||||
→ MAIN world resolves exact conversation.cid
|
||||
→ OneTalk history SDK request
|
||||
→ /r/MessageManager/listUserMessages WebSocket response
|
||||
→ body.userMessageModels[].message.content
|
||||
→ media decoder
|
||||
→ image | file | unsupported
|
||||
→ page bridge typed result
|
||||
→ IndexedDB / Bright / Mind consumers only read normalized content
|
||||
```
|
||||
|
||||
Raw `content.custom.data` 只能在 MAIN world 短暂存在。页面桥、IndexedDB、Bright 和 Mind 不得继续解析或持久化 OneTalk raw payload。
|
||||
|
||||
## 12. 错误处理
|
||||
|
||||
- 非法 Base64:`invalid_base64`。
|
||||
- UTF-8 解码失败:`invalid_utf8`。
|
||||
- JSON 解析失败:`invalid_json`。
|
||||
- 字段缺失、类型错误、数值溢出、URL 不合法:`invalid_schema`。
|
||||
- 输入超过固定上限:`payload_too_large`。
|
||||
- 合法但不支持的卡片:返回 `unsupported`,不是 `invalid`。
|
||||
- 任一失败都不得静默变为空文本、空 URL 或猜测的附件。
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
- JPEG 样本被规范化为 `kind="image"`,字段与 SDK `originalData` 一致。
|
||||
- ZIP、PDF 样本都被规范化为 `kind="file"`,共同满足 `cardType=12`。
|
||||
- ZIP 返回 payload 自带的下载 URL。
|
||||
- PDF 当前返回 `downloadUrl=null`、`previewUrl=params.url`。
|
||||
- `cardType=2000` 返回 `unsupported`,不识别为文件。
|
||||
- 顶层 SDK `message.content` 不作为图片或附件 URL。
|
||||
- `activeAccountId` 不作为 `conversationCode`;请求使用唯一匹配会话的 `conversation.cid`。
|
||||
- SDK 入口返回 `undefined` 时,适配器仍通过异步响应边界得到正确消息页,不读取同步返回值。
|
||||
- URL 缺失或动作不支持时返回明确状态,不改写 query 参数。
|
||||
- 页面桥之后只存在 normalized union,不包含 raw `custom.data`、认证值或完整 SDK payload。
|
||||
|
||||
## 14. 尚未确认
|
||||
|
||||
- 图片是否存在 payload 自带的独立下载 URL。
|
||||
- PDF 是否有页面 SDK 提供的正式“获取下载 URL”动作。
|
||||
- URL 是否依赖 OneTalk Cookie、有效期多长,以及能否在 Mind Origin 使用。
|
||||
- TXT、DOCX、XLSX 等附件是否与 ZIP/PDF 完全同构。
|
||||
- live 图片/附件 push 是否与历史消息使用相同 raw schema。
|
||||
@@ -0,0 +1,904 @@
|
||||
# OneTalk 消息内容原始格式与媒体同步能力调查报告
|
||||
|
||||
> 调查日期:2026-09-02
|
||||
>
|
||||
> 适用页面:`https://onetalk.alibaba.com/message/weblitePWA.htm`
|
||||
>
|
||||
> 调试环境:Chromium `154.0.8012.0`,CDP `127.0.0.1:9222`,Trade Message Center `0.8.6`
|
||||
>
|
||||
> 调查方式:通过 Chromium CDP 观察真实 OneTalk WebSocket 响应、调用页面只读历史 SDK,并对照扩展 IndexedDB、共享协议和 Bright 存储代码。调查过程中没有发送消息、没有调用会改变已读状态的 API,也没有记录正文、账号、token、完整 URL 或 URL 查询参数值。
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
当前系统并不是完全没有采集图片和附件,而是只实现了文本的**语义化解析**:
|
||||
|
||||
- 文本能够从 `content.text.content` 提取为 `message.text`。
|
||||
- 图片和附件能够作为原始 `contentType=101/custom` JSON 被观察、写入 IndexedDB,并通过 Bright ACK。
|
||||
- 图片和附件没有规范化的 `kind`、文件名、扩展名、大小、宽高、缩略图或下载地址合同。
|
||||
- 消费端如果只读取 `message.text`,就会表现为“文本存在,图片和附件不存在”。
|
||||
|
||||
真实样本已确认以下映射:
|
||||
|
||||
| 业务类型 | WebSocket 原始类型 | Base64 解码后的判定 | OneTalk SDK 归一化类型 |
|
||||
| ------------ | ------------------------------------ | -------------------- | --------------------------- |
|
||||
| 文本 | `contentType=1` | `text.content` | `msgType=101, subType=1` |
|
||||
| 图片 | `contentType=101, custom.type=7` | 图片元数据对象 | `msgType=102, subType=60` |
|
||||
| 文件附件 | `contentType=101, custom.type=10010` | `cardType=12` | `msgType=10010, subType=61` |
|
||||
| 其它业务卡片 | `contentType=101, custom.type=10010` | 例如 `cardType=2000` | 不是附件 |
|
||||
|
||||
本次真实附件样本为 PDF;没有抓到 TXT 附件、实时图片 push 或实时文件 push,因此这些场景不能标记为已验证。
|
||||
|
||||
另有一个必须优先修复的安全问题:当前文本内容的 `text.extension.basicMessageInfo` 是一段序列化 JSON,真实样本中包含 `chatToken` 键。页面观察器会复制整个原始 `content`,而 Bright 的清洗器对字符串直接原样放行,因此嵌套在字符串或 Base64 中的敏感字段可能进入持久化。
|
||||
|
||||
## 2. 调查范围与证据边界
|
||||
|
||||
### 2.1 使用的 Chromium/CDP 启动方式
|
||||
|
||||
```bash
|
||||
/Applications/Chromium.app/Contents/MacOS/Chromium \
|
||||
--remote-debugging-address=127.0.0.1 \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="/Users/ybf/work/demo-playwright/.chromium-profile"
|
||||
```
|
||||
|
||||
CDP 确认:
|
||||
|
||||
```text
|
||||
Browser: Chrome/154.0.8012.0
|
||||
Protocol-Version: 1.3
|
||||
```
|
||||
|
||||
### 2.2 只读历史方法
|
||||
|
||||
调查只调用了页面现有的只读方法:
|
||||
|
||||
```js
|
||||
const sdk = window.IcbuIM.IMBaaSSDK.default;
|
||||
const conversationService = sdk.getConversationServiceV2();
|
||||
const messageService = sdk.getMessageService();
|
||||
|
||||
await conversationService.getConversationListByPagination({
|
||||
cursor: 0,
|
||||
count: 20,
|
||||
});
|
||||
|
||||
await messageService.fetchMessagesWithoutUpdateToRead(options, conversation);
|
||||
```
|
||||
|
||||
没有调用以下会改变已读状态或页面 store 的方法:
|
||||
|
||||
```text
|
||||
fetchMessages
|
||||
updateMessageToRead
|
||||
```
|
||||
|
||||
### 2.3 本次运行态证据
|
||||
|
||||
本次调查完成时,扩展 IndexedDB 快照为:
|
||||
|
||||
```text
|
||||
数据库:trade-message-center
|
||||
版本:4
|
||||
|
||||
onetalk_messages:135
|
||||
contentType=1:125
|
||||
contentType=101/custom.type=7:1
|
||||
contentType=101/custom.type=10010:9
|
||||
|
||||
onetalk_sync_candidates:135
|
||||
confirmed:135
|
||||
anomaly:0
|
||||
|
||||
onetalk_sync_checkpoints:2
|
||||
phase=uploading:2
|
||||
syncResult=succeeded:2
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `custom.type=10010` 同时包含文件和非文件业务卡片,不能把 9 条全部解释为附件。
|
||||
- `confirmed` 表示插件已经收到 Bright 的 `accepted` 或 `duplicate` ACK,不表示 Mind UI 已经能渲染媒体。
|
||||
- checkpoint 仍为 `uploading`,说明同步完成/锚点最终收敛是另一个独立问题;它不改变本报告对消息内容格式的判断。
|
||||
|
||||
## 3. OneTalk 历史 WebSocket 原始响应
|
||||
|
||||
### 3.1 顶层 envelope
|
||||
|
||||
真实 WebSocket 历史响应结构如下。所有业务值都已省略:
|
||||
|
||||
```json
|
||||
{
|
||||
"headers": {
|
||||
"<protocol-header>": "<redacted>"
|
||||
},
|
||||
"code": 200,
|
||||
"body": {
|
||||
"degradeFailover": "<value>",
|
||||
"hasMore": 1,
|
||||
"nextCursor": "<cursor>",
|
||||
"userMessageModels": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次网络原帧中的分页字段为:
|
||||
|
||||
```text
|
||||
body.hasMore:0 | 1
|
||||
body.nextCursor:number | null
|
||||
```
|
||||
|
||||
### 3.2 单条历史消息包装
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"cid": "<raw-conversation-id>",
|
||||
"messageId": "<raw-message-id>",
|
||||
"createAt": 0,
|
||||
"content": {},
|
||||
"displayStyle": "<value>",
|
||||
"extension": "<value>",
|
||||
"msgReadStatusSetting": "<value>",
|
||||
"receiverCount": 0,
|
||||
"receivers": [],
|
||||
"redPointPolicy": "<value>",
|
||||
"searchableContent": "<value>",
|
||||
"sender": {
|
||||
"uid": "<participant-id>"
|
||||
},
|
||||
"unreadCount": 0
|
||||
},
|
||||
"msgStatus": 1,
|
||||
"readStatus": 2,
|
||||
"recallFeature": "<value>",
|
||||
"userExtension": "<value>"
|
||||
}
|
||||
```
|
||||
|
||||
当前页面观察器正是从这一层读取:
|
||||
|
||||
```text
|
||||
message.cid
|
||||
message.messageId
|
||||
message.createAt
|
||||
message.content
|
||||
message.sender.uid
|
||||
message.unreadCount
|
||||
item.msgStatus
|
||||
item.readStatus
|
||||
```
|
||||
|
||||
## 4. OneTalk SDK 返回结构
|
||||
|
||||
同一次请求在 SDK Promise 中返回的不是 WebSocket 原始 envelope,而是 SDK 归一化后的扁平结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"hasMore": true,
|
||||
"list": [],
|
||||
"nextCursor": 0
|
||||
}
|
||||
```
|
||||
|
||||
这里的字段类型与网络原帧不同:
|
||||
|
||||
```text
|
||||
SDK hasMore:boolean
|
||||
WebSocket hasMore:0 | 1
|
||||
```
|
||||
|
||||
SDK `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
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `content` 已经被 SDK 转成展示字符串。
|
||||
- 媒体的结构化字段位于 `originalData`。
|
||||
- 现有扩展历史观察链使用 WebSocket 原始消息作为事实来源,不应直接切换为 SDK 扁平消息并替换现有消息 ID。
|
||||
- 更安全的做法是直接解码 WebSocket `custom.data`,从而保留当前 `channelAccountId + conversationId + messageId` 幂等边界。
|
||||
|
||||
## 5. 文本原始格式
|
||||
|
||||
### 5.1 WebSocket 原始 content
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 1,
|
||||
"text": {
|
||||
"content": "<message-text-redacted>",
|
||||
"extension": {
|
||||
"basicMessageInfo": "<serialized-json-string>",
|
||||
"messageDisplayInfo": "<serialized-json-string>",
|
||||
"messageEventInfo": "<serialized-json-string>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 SDK 归一化结果
|
||||
|
||||
```text
|
||||
msgType:101
|
||||
type:1
|
||||
subType:1
|
||||
viewType:0
|
||||
content:string
|
||||
originalData keys:text
|
||||
```
|
||||
|
||||
### 5.3 当前可以安全保留的字段
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "<message-text>"
|
||||
}
|
||||
```
|
||||
|
||||
不应跨页面边界发送:
|
||||
|
||||
```text
|
||||
text.extension
|
||||
basicMessageInfo
|
||||
messageDisplayInfo
|
||||
messageEventInfo
|
||||
```
|
||||
|
||||
真实 `basicMessageInfo` 字符串解析后包含 `chatToken` 键,因此不能把这段字符串当作普通文本保存。
|
||||
|
||||
## 6. 图片原始格式
|
||||
|
||||
### 6.1 WebSocket 原始 content
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 7,
|
||||
"data": "<base64-json>",
|
||||
"degrade": "",
|
||||
"summary": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次图片样本:
|
||||
|
||||
```text
|
||||
custom.data 字符长度:532
|
||||
Base64 解码后 UTF-8 长度:397
|
||||
Base64 解码结果:JSON object
|
||||
```
|
||||
|
||||
### 6.2 Base64 解码后的 JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"fileId": "<file-id>",
|
||||
"height": 1188,
|
||||
"isOriginal": 1,
|
||||
"md5": "<md5>",
|
||||
"size": 370256,
|
||||
"suffix": "jpeg",
|
||||
"url": "<https-action-url>",
|
||||
"width": 1192
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 SDK 归一化结果
|
||||
|
||||
```text
|
||||
msgType:102
|
||||
type:1
|
||||
subType:60
|
||||
viewType:0
|
||||
```
|
||||
|
||||
SDK `originalData` 的字段与 Base64 解码结果一致:
|
||||
|
||||
```text
|
||||
fileId
|
||||
height
|
||||
isOriginal
|
||||
md5
|
||||
size
|
||||
suffix
|
||||
url
|
||||
width
|
||||
```
|
||||
|
||||
### 6.4 可实现的规范化图片合同
|
||||
|
||||
```ts
|
||||
type OneTalkImageContent = {
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
};
|
||||
```
|
||||
|
||||
规范化规则:
|
||||
|
||||
- `custom.type` 必须为 `7`。
|
||||
- `custom.data` 必须是有大小上限的合法 Base64。
|
||||
- Base64 解码结果必须是 UTF-8 JSON object。
|
||||
- `fileId`、`suffix`、`url` 必须为非空字符串。
|
||||
- `size`、`width`、`height` 必须为有限非负整数,并设置合理上限。
|
||||
- `isOriginal` 的 `0/1` 显式转换为 boolean。
|
||||
- `url` 只接受绝对 HTTPS URL,并在确认真实主机后加入固定 host allowlist。
|
||||
- 不根据 `suffix` 猜造 OneTalk 未提供的 MIME;UI 可以使用安全扩展名映射做展示提示。
|
||||
|
||||
## 7. 文件附件原始格式
|
||||
|
||||
### 7.1 WebSocket 原始 content
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 10010,
|
||||
"data": "<base64-json>",
|
||||
"degrade": "",
|
||||
"summary": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次 PDF 样本:
|
||||
|
||||
```text
|
||||
custom.data 字符长度:912
|
||||
Base64 解码后 UTF-8 长度:680
|
||||
Base64 解码结果:JSON object
|
||||
```
|
||||
|
||||
### 7.2 Base64 解码后的 JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"cardType": 12,
|
||||
"params": {
|
||||
"ctime": "<epoch-string>",
|
||||
"downloadUrl": "",
|
||||
"extensionType": "pdf",
|
||||
"id": "<file-id>",
|
||||
"md5": "<md5>",
|
||||
"name": "<redacted>.pdf",
|
||||
"parentId": "<parent-id>",
|
||||
"size": "<decimal-string>",
|
||||
"thumbnailUrl": "<https-action-url>",
|
||||
"type": "<value>",
|
||||
"url": "<https-action-url>",
|
||||
"version": "<value>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
重要事实:
|
||||
|
||||
- 文件判定不能只看 `custom.type=10010`。
|
||||
- 真实附件还需要 `cardType=12`。
|
||||
- `params.size` 是 decimal string,不是 number。
|
||||
- `params.downloadUrl` 在本次样本中为空。
|
||||
- 实际可用候选位于 `params.url`;`thumbnailUrl` 是另一个操作地址。
|
||||
- URL 路径表现为 `.htm` 操作入口,并带有 `appkey`、`fileAction`、`id`、`parentId`、`scene`、`secOperateAliId` 等查询键。报告没有记录查询值。
|
||||
|
||||
### 7.3 SDK 归一化结果
|
||||
|
||||
```text
|
||||
msgType:10010
|
||||
type:1
|
||||
subType:61
|
||||
viewType:0
|
||||
originalData.cardType:12
|
||||
originalData.params.extensionType:pdf
|
||||
```
|
||||
|
||||
### 7.4 可实现的规范化文件合同
|
||||
|
||||
```ts
|
||||
type OneTalkFileContent = {
|
||||
kind: "file";
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
```
|
||||
|
||||
规范化规则:
|
||||
|
||||
- `contentType` 必须为 `101`。
|
||||
- `custom.type` 必须为 `10010`。
|
||||
- Base64 JSON 的 `cardType` 必须为 `12`。
|
||||
- `params.id`、`params.name`、`params.extensionType`、`params.size`、`params.url` 必须通过边界校验。
|
||||
- `params.size` 只接受十进制正整数字符串,并转换为安全整数。
|
||||
- `name` 后缀和 `extensionType` 不一致时 fail closed 或降级为 unsupported,不静默选择其中一个。
|
||||
- `url`、非空的 `downloadUrl`、`thumbnailUrl` 只接受绝对 HTTPS URL 和固定 host allowlist。
|
||||
- 文件名用于展示时必须由 UI 作为文本渲染,不得作为 HTML。
|
||||
|
||||
PDF 已有真实样本。TXT 是否沿用完全相同的 `cardType=12` 结构尚无真实证据;实现可以设计为通用文件合同,但 TXT 的验收必须等待真实样本。
|
||||
|
||||
## 8. `custom.type=10010` 不等于附件
|
||||
|
||||
本次还抓到以下 WebSocket 原始类型:
|
||||
|
||||
```text
|
||||
contentType=101
|
||||
custom.type=10010
|
||||
custom.data=<base64-json>
|
||||
```
|
||||
|
||||
但 Base64 解码结果为:
|
||||
|
||||
```json
|
||||
{
|
||||
"cardType": 2000,
|
||||
"params": {
|
||||
"country": "<value>",
|
||||
"ctime": "<value>",
|
||||
"ids": "<value>",
|
||||
"type": "<value>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
SDK 将其归一化为:
|
||||
|
||||
```text
|
||||
msgType=10010
|
||||
subType=2000
|
||||
```
|
||||
|
||||
这是一类业务卡片,不是附件。若代码只写:
|
||||
|
||||
```ts
|
||||
if (content.custom.type === 10010) return file;
|
||||
```
|
||||
|
||||
就会错误地把商品、国家或其它业务卡片当成文件。
|
||||
|
||||
正确判定必须至少包含:
|
||||
|
||||
```text
|
||||
contentType=101
|
||||
custom.type=10010
|
||||
decoded.cardType=12
|
||||
decoded.params 通过文件 schema
|
||||
```
|
||||
|
||||
其它合法但未支持的 `cardType` 应返回受控的 `unsupported` 内容,不应丢弃整条消息,也不应保留完整 raw payload。
|
||||
|
||||
## 9. 当前代码为什么表现为“只有文本”
|
||||
|
||||
### 9.1 页面观察器
|
||||
|
||||
当前 `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts`:
|
||||
|
||||
```ts
|
||||
const contentRecord = isRecord(message.content) ? message.content : null;
|
||||
|
||||
if (contentRecord && isFiniteNumber(contentRecord.contentType)) {
|
||||
output.contentType = contentRecord.contentType;
|
||||
}
|
||||
|
||||
if (contentRecord) output.content = contentRecord;
|
||||
|
||||
const contentText = contentRecord?.text;
|
||||
if (isRecord(contentText) && typeof contentText.content === "string") {
|
||||
output.text = contentText.content;
|
||||
} else {
|
||||
output.text = null;
|
||||
}
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- 文本:得到 `contentType=1`、完整 raw content 和 `text=正文`。
|
||||
- 图片/文件:得到 `contentType=101`、完整 raw content 和 `text=null`。
|
||||
- 当前没有任何代码把 Base64 `custom.data` 变成 `image` 或 `file`。
|
||||
|
||||
### 9.2 Service Worker 与 IndexedDB
|
||||
|
||||
`pageMessageToObserved()` 会复制页面观察字段。IndexedDB 只检查:
|
||||
|
||||
```text
|
||||
content 是合法 JSON value
|
||||
contentType 是有限整数
|
||||
text 是 string | null | undefined
|
||||
```
|
||||
|
||||
所以图片和文件不会因为 `text=null` 被拒绝。它们以 opaque JSON 进入:
|
||||
|
||||
```text
|
||||
onetalk_messages
|
||||
onetalk_sync_candidates
|
||||
```
|
||||
|
||||
本次运行态的 `confirmed` 计数证明图片/附件 raw 记录确实通过了现有 ACK 链路。
|
||||
|
||||
### 9.3 共享协议与 Bright
|
||||
|
||||
当前 `OneTalkMessage` 定义:
|
||||
|
||||
```ts
|
||||
type OneTalkMessage = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
direction: "sent" | "received";
|
||||
sentAtMs: number;
|
||||
content: OneTalkJsonValue;
|
||||
contentType: number;
|
||||
text?: string | null;
|
||||
participantIds: string[];
|
||||
readStatus: number;
|
||||
messageStatus: number;
|
||||
unreadCount: number;
|
||||
};
|
||||
```
|
||||
|
||||
`content` 只有“可序列化 JSON”约束,没有 `text/image/file` 语义。
|
||||
|
||||
Bright 数据库当前保存:
|
||||
|
||||
```text
|
||||
content_type integer
|
||||
text text nullable
|
||||
content jsonb
|
||||
```
|
||||
|
||||
因此 Bright 能保存媒体 raw JSON,但不能告诉 Mind:
|
||||
|
||||
```text
|
||||
这是图片还是文件
|
||||
哪个 URL 用于预览
|
||||
哪个 URL 用于下载
|
||||
文件名和大小是什么
|
||||
未知 card 应如何展示
|
||||
```
|
||||
|
||||
### 9.4 当前展示边界
|
||||
|
||||
仓库内的 Bright harness 只执行:
|
||||
|
||||
```js
|
||||
JSON.stringify({ content: message.content }, null, 2);
|
||||
```
|
||||
|
||||
它没有:
|
||||
|
||||
```text
|
||||
<img>
|
||||
附件链接
|
||||
文件名
|
||||
文件大小
|
||||
类型图标
|
||||
unsupported fallback
|
||||
```
|
||||
|
||||
真实 Mind UI 如果只读取 `message.text`,媒体消息自然不可见。
|
||||
|
||||
## 10. 当前安全风险
|
||||
|
||||
### 10.1 字符串内部的敏感字段绕过清洗
|
||||
|
||||
Bright 当前的敏感键规则能删除普通对象中的:
|
||||
|
||||
```text
|
||||
token
|
||||
cookie
|
||||
csrf
|
||||
authorization
|
||||
secret
|
||||
password
|
||||
credential
|
||||
sid
|
||||
app-key
|
||||
```
|
||||
|
||||
但清洗器遇到字符串会立即原样返回:
|
||||
|
||||
```ts
|
||||
if (typeof value === "string" || typeof value === "boolean") return value;
|
||||
```
|
||||
|
||||
以下两种载荷因此不会被递归检查:
|
||||
|
||||
1. `text.extension.basicMessageInfo` 中的序列化 JSON。
|
||||
2. `custom.data` 中的 Base64 JSON。
|
||||
|
||||
真实 `basicMessageInfo` 解析后已确认含有 `chatToken` 键。当前实现把完整 raw content 交给 Bright,违反了数据库注释中“不得写入带认证信息的完整 envelope”的不变量。
|
||||
|
||||
### 10.2 推荐的安全边界
|
||||
|
||||
媒体支持不能靠“服务端收到 raw 后再尽量清洗”。应改为:
|
||||
|
||||
```text
|
||||
OneTalk raw message
|
||||
→ MAIN world 严格解码
|
||||
→ 显式构造白名单 OneTalkMessageContent
|
||||
→ 页面桥
|
||||
→ Service Worker exact decoder
|
||||
→ Bright exact decoder
|
||||
→ JSONB / message.created / HTTP history
|
||||
```
|
||||
|
||||
禁止跨边界的字段包括:
|
||||
|
||||
```text
|
||||
raw content object
|
||||
text.extension
|
||||
basicMessageInfo
|
||||
custom.data
|
||||
chatToken
|
||||
Cookie
|
||||
Authorization
|
||||
完整 OneTalk response/envelope
|
||||
原始 SDK row
|
||||
```
|
||||
|
||||
### 10.3 URL 风险
|
||||
|
||||
图片和文件样本中的 URL 是带查询参数的 HTTPS 操作地址,而不是直接媒体 URL。实现前还必须验证:
|
||||
|
||||
- 是否依赖 OneTalk 登录 Cookie。
|
||||
- 是否包含短期授权或一次性参数。
|
||||
- 是否可以从 Mind 页面所在 Origin 使用。
|
||||
- 是否会发生 302 跳转,以及跳转地址的生命周期。
|
||||
- 是否允许服务端代理;若允许,如何做授权和 SSRF 防护。
|
||||
- URL 是否适合长期持久化,还是只能保存媒体 ID 并在需要时换取临时地址。
|
||||
|
||||
在上述行为没有真实验证前,只能称其为 `sourceUrl`,不能承诺“Mind 可直接下载”。
|
||||
|
||||
## 11. 推荐的目标内容合同
|
||||
|
||||
建议让 `content` 成为消息展示内容的唯一事实源:
|
||||
|
||||
```ts
|
||||
type OneTalkNormalizedContent =
|
||||
| {
|
||||
kind: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
| {
|
||||
kind: "unsupported";
|
||||
sourceContentType: number;
|
||||
sourceCustomType?: number;
|
||||
cardType?: number;
|
||||
};
|
||||
```
|
||||
|
||||
兼容期可以保留顶层 `message.text`,但它必须始终从:
|
||||
|
||||
```ts
|
||||
message.content.kind === "text" ? message.content.text : null;
|
||||
```
|
||||
|
||||
派生,不能成为第二个可独立写入的事实源。
|
||||
|
||||
现有 JSONB 列可以保存这个 union。只有在需要文件类型索引、全文搜索或独立媒体生命周期时,才需要新增数据库列或媒体表;第一阶段不应为了渲染图片/PDF创建第二套消息表。
|
||||
|
||||
## 12. 可以做到什么
|
||||
|
||||
### 12.1 当前已经做到
|
||||
|
||||
- 观察文本、图片和 custom card 的原始 WebSocket content。
|
||||
- 使用 raw `channelAccountId + conversationId + messageId` 保持消息幂等。
|
||||
- 将非文本消息写入 IndexedDB。
|
||||
- 将非文本消息作为 JSON observation 发送给 Bright。
|
||||
- Bright 可以 ACK 图片/附件 raw observation。
|
||||
- Bright JSONB 可以保存规范化后的 `text/image/file/unsupported` union,无需立即增加第二张消息表。
|
||||
|
||||
### 12.2 基于现有证据可以立即实现
|
||||
|
||||
- 文本严格白名单提取,删除全部 extension。
|
||||
- `custom.type=7` 图片 Base64 JSON 解码和字段校验。
|
||||
- `custom.type=10010 + cardType=12` 通用文件解码。
|
||||
- PDF 文件名、扩展名、大小、源 URL、缩略图元数据同步。
|
||||
- 未支持 card 的安全 fallback,不丢失消息身份和时间线位置。
|
||||
- Mind 侧按 `content.kind` 渲染文本、图片占位/预览和附件链接。
|
||||
- 在共享协议、HTTP history 和 `message.created` 中使用同一 typed content。
|
||||
- 添加敏感字段负向测试,证明 raw content、Base64 和序列化 JSON 不再跨边界。
|
||||
|
||||
### 12.3 当前还不能承诺
|
||||
|
||||
- TXT 附件与 PDF 完全同构:没有真实 TXT 样本。
|
||||
- 实时图片/文件 push 与历史响应完全同构:没有实时媒体 push 样本。
|
||||
- `sourceUrl` 能在 Mind 页面直接打开:未验证 Cookie、CORS、跳转和有效期。
|
||||
- 图片/附件可以永久下载:当前 URL 可能只是临时操作入口。
|
||||
- 支持音频、视频、语音、压缩包或所有 OneTalk custom card:没有真实样本和枚举。
|
||||
- checkpoint 已完整收敛:本次末态仍为 `uploading/succeeded`。
|
||||
|
||||
## 13. 推荐 PRD 要求
|
||||
|
||||
### R1. 单一内容合同
|
||||
|
||||
扩展、Bright 和 Mind 必须共用一个 `OneTalkNormalizedContent` 判别联合。页面 raw payload、服务端 JSONB 和 UI 本地模型不得分别维护不同的媒体判定逻辑。
|
||||
|
||||
### R2. 页面边界白名单解码
|
||||
|
||||
MAIN world 是 OneTalk raw content 的唯一解码所有者:
|
||||
|
||||
- 文本只取 `text.content`。
|
||||
- 图片只接受 `contentType=101/custom.type=7` 的有效 Base64 JSON。
|
||||
- 文件只接受 `contentType=101/custom.type=10010/cardType=12`。
|
||||
- 其它类型输出有限 `unsupported` 元数据。
|
||||
- raw content 和 custom data 不得进入页面桥。
|
||||
|
||||
### R3. 保留事实身份
|
||||
|
||||
媒体规范化不得改变现有幂等键:
|
||||
|
||||
```text
|
||||
channelAccountId + conversationId + raw messageId
|
||||
```
|
||||
|
||||
不得用 SDK 展示层 ID、时间戳、hash 或合成 ID 代替 raw message ID。
|
||||
|
||||
### R4. URL 与文件元数据校验
|
||||
|
||||
- URL 只允许 HTTPS 和固定 OneTalk host allowlist。
|
||||
- URL query/hash/userinfo 不进入日志或错误。
|
||||
- 文件大小、宽高和时间字段必须有上下限。
|
||||
- 文件扩展名规范化为小写有限字符集。
|
||||
- 文件名按纯文本处理并限制长度。
|
||||
- MD5 只能作为来源元数据,不能替代消息 ID 或安全签名。
|
||||
|
||||
### R5. 历史与实时一致
|
||||
|
||||
历史 `body.userMessageModels[].message.content` 和实时 `lastMessage.message.content` 必须走同一个 decoder。只有在真实 live 媒体样本证明 envelope 不同后,才允许增加窄化适配层。
|
||||
|
||||
### R6. 未支持类型行为
|
||||
|
||||
合法但未支持的 custom card:
|
||||
|
||||
- 不伪装成文本或附件。
|
||||
- 不保存 raw content。
|
||||
- 保留消息事实并使用 `kind=unsupported`。
|
||||
- 输出稳定的安全类型码或诊断,不输出 raw data。
|
||||
|
||||
非法 Base64、非法 JSON、字段类型错误、超限或非 HTTPS URL:
|
||||
|
||||
- 进入明确 anomaly 或受控 unsupported 策略。
|
||||
- 不得静默返回空文本。
|
||||
- 不得阻塞同批其它有效消息。
|
||||
|
||||
### R7. 跨层一致性
|
||||
|
||||
以下边界必须同时升级并由共享 decoder 约束:
|
||||
|
||||
```text
|
||||
MAIN observed message
|
||||
page bridge
|
||||
Service Worker storage/candidate
|
||||
message.observed
|
||||
Bright normalization
|
||||
PostgreSQL JSONB
|
||||
message.created
|
||||
HTTP history
|
||||
Mind UI model
|
||||
```
|
||||
|
||||
如果合同为不兼容变更,应提升 OneTalk 协议版本并让旧插件得到明确升级错误,不能在同一版本内让 `content` 同时表示 raw 与 normalized 两种含义。
|
||||
|
||||
### R8. 安全清除
|
||||
|
||||
任何通过网络、数据库、日志或诊断的消息都必须证明不包含:
|
||||
|
||||
```text
|
||||
chatToken
|
||||
basicMessageInfo
|
||||
custom.data
|
||||
Cookie
|
||||
Authorization
|
||||
accountIdEncrypt
|
||||
aliIdEncrypt
|
||||
原始 URL query 值
|
||||
完整 raw payload
|
||||
```
|
||||
|
||||
## 14. 推荐验收标准
|
||||
|
||||
- [ ] 真实文本历史消息归一化为 `{ kind: "text", text }`,并且 content 不含 extension。
|
||||
- [ ] 真实 JPEG 样本从 `custom.type=7` Base64 JSON 归一化为 image,尺寸、大小、后缀与真实载荷一致。
|
||||
- [ ] 真实 PDF 样本从 `custom.type=10010/cardType=12` 归一化为 file,文件名、扩展名、大小和 URL 元数据一致。
|
||||
- [ ] `custom.type=10010/cardType=2000` 不会被识别成文件。
|
||||
- [ ] 非法 Base64、非法 JSON、超限数据、非 HTTPS URL、缺字段和大小溢出均 fail closed,不影响同批其它消息。
|
||||
- [ ] page bridge、IndexedDB、Bright DB、HTTP history 和 `message.created` 中均只存在 normalized content。
|
||||
- [ ] 序列化最终 frame、数据库输入和诊断事件,断言不包含 `chatToken`、`basicMessageInfo`、`custom.data` 或加密身份字段。
|
||||
- [ ] Mind UI 能渲染文本、图片状态和 PDF 附件;URL 不可用时显示明确失败,不显示空白消息。
|
||||
- [ ] unknown custom card 显示“暂不支持的消息类型”,同时保留正确的消息时间线和幂等身份。
|
||||
- [ ] 获得真实 TXT 样本后补充同构性测试;未获得前不宣称 TXT 已验证。
|
||||
- [ ] 获得真实 live 图片和附件 push 后证明历史与实时共用同一 decoder。
|
||||
- [ ] 验证媒体 URL 在有 Cookie、无 Cookie、跨 Origin、过期和跳转场景的行为,再决定保存 URL、保存 ID 或增加受控代理。
|
||||
|
||||
## 15. 相关代码位置
|
||||
|
||||
| 层 | 文件 | 当前行为 |
|
||||
| -------------- | --------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| WebSocket 分流 | `apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts` | 识别 history/new envelope |
|
||||
| 页面消息提取 | `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts` | 复制 raw content,仅提取 text |
|
||||
| 历史只读 SDK | `apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts` | 调用 `fetchMessagesWithoutUpdateToRead` |
|
||||
| 页面到 SW | `apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts` | 原样复制观察字段 |
|
||||
| IndexedDB 校验 | `apps/chrome-extension/src/onetalk/service-worker/storage.ts` | 只检查 JSON 与基础字段类型 |
|
||||
| 共享合同 | `apps/onetalk-contract/src/model.ts` | `content` 为任意 JSON value |
|
||||
| 共享 decoder | `apps/onetalk-contract/src/decoder.ts` | 不理解 text/image/file 语义 |
|
||||
| Bright 清洗 | `apps/server/src/onetalk/service.ts` | 按键过滤对象,字符串原样通过 |
|
||||
| Bright 持久化 | `apps/server/src/database/schema/onetalk.ts` | `content jsonb`、`text nullable` |
|
||||
| 开发展示 | `apps/server/src/http/harness.ts` | 只展示 raw JSON |
|
||||
| 现有媒体覆盖 | `apps/chrome-extension/test/onetalk-websocket-tap.test.js` | 只有简化 `custom.type=10010` fixture |
|
||||
|
||||
## 16. 最终判断
|
||||
|
||||
OneTalk 当前真实载荷已经提供实现图片和文件同步所需的核心元数据,且 raw `custom.data` 可以在页面内稳定识别为 Base64 JSON。第一阶段不需要下载媒体文件,也不需要新建第二套消息表;可以在现有消息链上增加一个唯一的内容 decoder 和跨层 typed contract。
|
||||
|
||||
实现工作的核心不是“把 `contentType=101` 放行”,因为当前已经放行;真正需要完成的是:
|
||||
|
||||
```text
|
||||
opaque raw content
|
||||
→ 严格、安全、可测试的 text/image/file/unsupported 合同
|
||||
→ Bright 持久化与事件保持同一语义
|
||||
→ Mind 按 kind 展示
|
||||
```
|
||||
|
||||
同时必须先关闭 raw 字符串中嵌套凭证可能进入 Bright 的安全缺口,否则新增媒体支持会进一步扩大敏感 payload 的存储范围。
|
||||
Reference in New Issue
Block a user