mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
924 lines
28 KiB
Markdown
924 lines
28 KiB
Markdown
# 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 查询参数值。
|
||
>
|
||
> v6 同步说明(2026-09-11):下列 raw 样本和当时能力结论保留为调查证据;当前规范性图片合同为无尺寸的 `fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl`、`urlScope`(另有 `version`、`kind`)。MAIN decoder 忽略 raw `width` / `height`,它们不跨 normalized boundary;本报告不是 v6 live runtime 验收。
|
||
|
||
## 1. 结论摘要
|
||
|
||
以下结论是本报告调查时(2026-09-02)的历史快照:当时系统并不是完全没有采集图片和附件,而是只实现了文本的**语义化解析**:
|
||
|
||
- 文本能够从 `content.text.content` 提取为 `message.text`。
|
||
- 图片和附件能够作为原始 `contentType=101/custom` JSON 被观察、写入 IndexedDB,并通过 Bright ACK。
|
||
- 图片和附件当时没有规范化的 `kind`、文件名、扩展名、大小、缩略图或下载地址合同;当前 v6 image canonical contract 见第 6 节。
|
||
- 消费端如果只读取 `message.text`,就会表现为“文本存在,图片和附件不存在”。
|
||
|
||
这不是当前 v6 数据流。当前 raw OneTalk content 只在 MAIN decoder 内解码和收窄;页面桥、Service Worker、IndexedDB、Bright JSONB、HTTP read 和 Mind 下游只接收 normalized content,Bright 不保存 raw JSON。
|
||
|
||
真实样本已确认以下映射:
|
||
|
||
| 业务类型 | 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
|
||
```
|
||
|
||
这份 SDK 字段清单是历史 raw/boundary-only 证据,包含的 `originalData.width` / `originalData.height` 只可在 MAIN decoder 边界被忽略,不能成为 canonical 字段、存储字段或下游输入。
|
||
|
||
其中:
|
||
|
||
- `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
|
||
}
|
||
```
|
||
|
||
这是 OneTalk raw payload 证据,不是 normalized contract。raw `width` / `height` 可保留在此样本中,但 v6 MAIN decoder 忽略它们,绝不将其传过页面桥或写入 canonical content。
|
||
|
||
### 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 = {
|
||
version: 1;
|
||
kind: "image";
|
||
fileId: string;
|
||
extension: string;
|
||
sizeBytes: number;
|
||
isOriginal: boolean;
|
||
md5: string | null;
|
||
previewUrl: string | null;
|
||
urlScope: "onetalk_session";
|
||
};
|
||
```
|
||
|
||
规范化规则:
|
||
|
||
- `custom.type` 必须为 `7`。
|
||
- `custom.data` 必须是有大小上限的合法 Base64。
|
||
- Base64 解码结果必须是 UTF-8 JSON object。
|
||
- `fileId`、`suffix`、`url` 必须为非空字符串。
|
||
- `size` 必须为有限非负整数,并设置合理上限;raw `width` / `height` 被 MAIN decoder 忽略,不参与校验或输出。
|
||
- `isOriginal` 的 `0/1` 显式转换为 boolean。
|
||
- `url` 只接受绝对 HTTPS URL,并在确认真实主机后映射为 `previewUrl`;`urlScope` 固定为 `onetalk_session`。
|
||
- 不根据 `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. 当前 v6 内容边界
|
||
|
||
当前规范性数据流是:
|
||
|
||
```text
|
||
OneTalk raw message
|
||
→ MAIN decoder(raw 仅在此处出现)
|
||
→ exact normalized content
|
||
→ page bridge / Service Worker / IndexedDB
|
||
→ Bright canonical JSONB / message.created / HTTP history
|
||
→ Mind read model
|
||
```
|
||
|
||
因此 Bright 不保存 raw payload、`custom.data` 或 SDK row;它只保存共享 guard 已接受的 normalized content。下列合同说明该边界的目标形状。
|
||
|
||
建议让 `content` 成为消息展示内容的唯一事实源:
|
||
|
||
```ts
|
||
type OneTalkNormalizedContent =
|
||
| {
|
||
kind: "text";
|
||
text: string;
|
||
}
|
||
| {
|
||
version: 1;
|
||
kind: "image";
|
||
fileId: string;
|
||
extension: string;
|
||
sizeBytes: number;
|
||
isOriginal: boolean;
|
||
md5: string | null;
|
||
previewUrl: string | null;
|
||
urlScope: "onetalk_session";
|
||
}
|
||
| {
|
||
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. 当前 v6 不变量
|
||
|
||
### 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 不进入日志或错误。
|
||
- 文件大小和时间字段必须有上下限;图片 raw `width` / `height` 由 MAIN decoder 忽略,不能成为 normalized 字段、匹配条件或展示数据。
|
||
- 文件扩展名规范化为小写有限字符集。
|
||
- 文件名按纯文本处理并限制长度。
|
||
- 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 归一化为无尺寸 v6 image;`fileId`、`extension`、`sizeBytes`、`isOriginal`、`md5`、`previewUrl` 与 `urlScope` 通过 canonical contract,raw `width` / `height` 不跨 MAIN 边界。
|
||
- [ ] 真实 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 与基础字段类型 |
|
||
| 共享合同 | `packages/onetalk-contract/src/model.ts` | `content` 为任意 JSON value |
|
||
| 共享 decoder | `packages/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/mind-test-harness/src/harness.ts` | 只展示 raw JSON |
|
||
| 现有媒体覆盖 | `apps/chrome-extension/test/onetalk-websocket-tap.test.js` | 只有简化 `custom.type=10010` fixture |
|
||
|
||
## 16. 最终判断
|
||
|
||
历史调查证明 OneTalk raw payload 提供实现图片和文件同步所需的核心元数据。当前 v6 不再沿用该报告中 raw content 跨页面桥、IndexedDB、Bright 或 Mind 的历史路径;实现核心已经收敛为 MAIN decoder 的单一白名单边界:
|
||
|
||
```text
|
||
opaque raw content
|
||
→ 严格、安全、可测试的 text/image/file/unsupported 合同
|
||
→ Bright 持久化与事件只使用 normalized content
|
||
→ Mind read model 只接收 normalized content
|
||
```
|
||
|
||
raw 字符串中嵌套凭证进入 Bright 是本报告记录的历史风险;当前 v6 边界以不让 raw payload 离开 MAIN decoder 的方式关闭它。真实 v6 Chromium 运行态验收仍未执行。
|