docs(onetalk): document media message formats and sync plan

This commit is contained in:
YBF
2026-09-04 00:59:05 +08:00
parent a6e6769b06
commit 94aaad93e6
13 changed files with 3209 additions and 0 deletions
@@ -0,0 +1,489 @@
# OneTalk 图片与附件消息格式调查报告
> 调查日期:2026-09-01
> 调查对象:`https://onetalk.alibaba.com/message/weblitePWA.htm` 以及当前 `trade-message-center` OneTalk 扩展链路
> 调查方式:Chromium DevTools ProtocolCDP`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 消息和三态发送结果 |
U01U04、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 代码。
- 不执行图片/文件发送、上传或二进制下载。
- 不以静态代码检查代替真实运行态结论。
+432
View File
@@ -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。
+904
View File
@@ -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_messages135
contentType=1125
contentType=101/custom.type=71
contentType=101/custom.type=100109
onetalk_sync_candidates135
confirmed135
anomaly0
onetalk_sync_checkpoints2
phase=uploading2
syncResult=succeeded2
```
注意:
- `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.hasMore0 | 1
body.nextCursornumber | 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 hasMoreboolean
WebSocket hasMore0 | 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
msgType101
type1
subType1
viewType0
contentstring
originalData keystext
```
### 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
msgType102
type1
subType60
viewType0
```
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
msgType10010
type1
subType61
viewType0
originalData.cardType12
originalData.params.extensionTypepdf
```
### 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 的存储范围。