docs: OneTalk 文件发送与 WebSocket 观测可行性报告

This commit is contained in:
YBF
2026-09-10 15:49:45 +08:00
parent bb03113eb3
commit d3cf7753bf
@@ -0,0 +1,719 @@
# OneTalk 文件发送与 WebSocket 观测可行性报告
> 日期:2026-09-10
> 环境:OneTalk SaaS 测试环境,Chromium CDP `127.0.0.1:9222`
> 性质:运行态调查与可行性结论,不包含代码实现
> 隐私约束:本文不记录真实会话 ID、账号 ID、Token、Cookie、媒体完整 URL、URL 查询值或 MD5 原值
## 1. 结论摘要
OneTalk 文件发送和实时确认均可实现,但需要把“上传完成”和“消息发送确认”分成两个阶段:
1. 本地 `File` 先经过校验、MD5、云盘准备、可选 OSS 上传和文件关系建立。
2. 页面将关系结果转换为 `fileCard`,最终调用 `getMessageServiceV2().sendUIMessages(...)`
3. OneTalk 服务端通过 live WebSocket 回送完整 sent 文件消息。
4. MAIN-world observer 把 raw 文件卡片归一化为 `content.kind="file"`,随后才能生成可靠的 `confirmed_sent`
本次测试文件成功出现在目标会话历史中,并以 `observationSource="live"` 进入扩展 IndexedDB,证明 live WebSocket observer 可以取得文件名、扩展名、大小、MD5、资源 ID 和下载状态。
推荐结论:
- 有可靠候选消息 ID 时,优先按消息 ID 确认。
- 没有候选 ID 时,可以使用文件复合指纹进行回退匹配。
- 文件名、大小、扩展名或 MD5 均不能单独作为唯一键。
- pending 必须在上传/建关系完成后、最终 `sendUIMessages` 之前登记,不能从文件选择时就启动当前 10 秒发送确认计时器。
## 2. 调查范围与证据等级
### 2.1 已验证
- 浏览器原生文件选择器接受 `.zip`
- 页面按文件名后缀把测试文件识别为 ZIP。
- 云盘准备接口和文件关系接口实际调用成功。
- 文件消息实际进入目标会话只读历史。
- live WebSocket observer 实际产出 `content.kind="file"`
- 扩展 IndexedDB 中存在对应 `observationSource="live"` 文件记录。
- 当前媒体解码与 WebSocket observer 的 24 个定向测试全部通过。
### 2.2 本次未验证
- 真正的 OSS 二进制上传:测试文件命中去重/已有文件分支,因此没有发生 OSS POST。
- 接收方设备是否已展示或下载成功。
- 文件媒体 URL 的跨会话、跨 Origin、Cookie 依赖和有效期。
- 非当前页面会话的完整文件上传路径。本次文件测试时目标会话已经是当前选中会话。
- 同一文件并发发送时是否能够取得稳定的一一对应候选消息 ID。
## 3. 测试文件事实
测试文件:`简历.zip`
| 检查项 | 结果 |
| -------------------- | ----------------- |
| 文件大小 | `344838` bytes |
| 文件名后缀 | `.zip` |
| 浏览器 `File.type` | `application/zip` |
| 文件签名识别 | PDF 1.3,单页 |
| OneTalk 最终扩展类型 | `zip` |
这个样本说明页面和最终消息元数据按文件名/浏览器 MIME 将其当成 ZIP,但不能据此断言二进制内容确实是 ZIP。当前消息合同只包含元数据和资源引用,不包含文件本体,无法在消息 observer 内验证 magic bytes。
## 4. 文件发送路径
### 4.1 总体流程
```text
<input type="file" multiple>
-> FileList 转 File[],为每个 File 分配 uid
-> OneTalk beforeUpload 接管并阻止通用 Upload 自动提交
-> 文件校验
-> 计算 MD5
-> prepareSendFileWithGroup
-> 已存在:跳过 OSS 上传
不存在:按 policyDTO 上传至 OSS
-> buildFileRelationWithGroup
-> 得到 fileCardUrl / redirectFileUrl / fileId / parentId 等关系数据
-> sendFile 构造 fileCard 发送参数
-> messageBox/sendMessage
-> getMessageServiceV2().sendUIMessages
-> 本地乐观回执
-> BaaS WebSocket live sent 消息
-> observer 归一化并确认
```
### 4.2 文件选择与 `beforeUpload`
页面使用隐藏的多选文件控件。OneTalk 的 Upload 组件虽然配置了通用 `action`,但业务 `beforeUpload` 始终返回 `false`,因此文件不会由通用 Upload 直接发送,而是进入 OneTalk 自己的上传流程。
页面从当前会话上下文构造:
```ts
type UploadContext = {
file: File;
tmpKey: string;
traceId: string;
contact: {
cid: string;
conversationType: number;
accountId: string;
accountIdEncrypt?: string;
aliId?: string;
aliIdEncrypt?: string;
chatToken?: string;
contact?: { aliId?: string };
owner?: { aliId?: string };
};
fromTo: {
from: string;
to: string;
fromAliId: string;
toAliId: string;
};
};
```
这些字段必须来自目标会话完整对象,不能根据会话 ID 字符串拆分或猜测账号身份。
### 4.3 文件校验与 MD5
页面在上传前执行大小和会话身份校验,并以 2 MiB 分片通过 `FileReader.readAsArrayBuffer` 计算文件 MD5。
MD5 的用途包括:
- 构造云盘文件名 `<md5>.<extension>`
- 判断服务端是否已有相同文件。
- 建立文件关系。
- 生成最终文件卡片的媒体元数据。
MD5 不能作为消息唯一 ID:同一个文件重复发送时 MD5、大小、文件名甚至 fileId 都可能相同。
### 4.4 云盘准备接口
页面调用:
```text
GET https://acs.h.alibaba.com/h5/
mtop.alibaba.interaction.clouddisk.preparesendfilewithgroup/1.0/
```
概念参数结构:
```ts
{
api: "mtop.alibaba.interaction.clouddisk.prepareSendFileWithGroup",
v: "1.0",
appKey: "24889839",
data: {
appKey: "OneChat",
fileName: "<md5>.<extension>",
scene: JSON.stringify({
sceneType: "1" | "2", // 单聊 / 群聊
idType: "2",
from: "<from-ali-id>",
to: "<to-ali-id>",
}),
isToken: false,
},
ecode: 1,
dataType: "json",
}
```
响应中的关键业务字段包括:
```ts
{
fileIsExist: boolean;
uploadFileDir: string;
allowSendFileMaxSize: number;
policyDTO: {
host: string;
encodedPolicy: string;
accessid: string;
callbackBody: string;
signature: string;
}
}
```
这些策略值均属于运行时上传凭证,不得穿过页面边界、写入日志或持久化。
### 4.5 OSS 上传
仅当 `fileIsExist !== true` 时,页面才会向 `policyDTO.host` 发起文件上传。概念请求为:
```ts
{
method: "POST",
file,
filename: uploadFileDir + "<md5>.<extension>",
formData: {
key: "<object-key>",
policy: "<encoded-policy>",
OSSAccessKeyId: "<access-id>",
success_action_status: "200",
callback: "<callback-body>",
signature: "<signature>",
},
}
```
本次没有观察到该 POST。根据页面实现和后续直接进入建关系接口的行为,可以判断命中了服务端已有文件/去重分支。
### 4.6 建立文件关系
页面调用:
```text
GET https://acs.h.alibaba.com/h5/
mtop.alibaba.interaction.clouddisk.buildfilerelationwithgroup/1.0/
```
概念参数结构:
```ts
{
api: "mtop.alibaba.interaction.clouddisk.buildFileRelationWithGroup",
v: "1.0",
appKey: "24889839",
data: {
appKey: "OneChat",
scene: JSON.stringify({
sceneType: "1" | "2",
idType: "2" | "3",
from: "<sender-id>",
to: "<receiver-id>",
}),
node: JSON.stringify({
nodeName: "<original-file-name>",
nodeSize: 344838,
materialType: "zip",
md5: "<md5>",
bucket: "<bucket>",
extend: "<optional-extension-data>",
}),
params: JSON.stringify({
source: "",
needCardUrl: true,
}),
},
ecode: 1,
dataType: "json",
}
```
成功结果向后续发送阶段提供:
```text
fileId / id
parentId
fileCardUrl
redirectFileUrl
nodeName
nodeSize
materialType
md5
thumbnailUrl
downloadUrl
traceId
```
### 4.7 构造 `fileCard` 并发送
`sendFile` 根据 `materialType` 分类:
```text
图片扩展 -> imageCard
视频扩展 -> videoCard
其它扩展 -> fileCard
```
ZIP 进入 `fileCard` 分支。发送给 `messageBox/sendMessage` 的概念结构是:
```ts
{
sendType: "file",
tmpKey: "<local-temp-key>",
traceId: "<trace-id>",
contentType: "fileCard",
scene: "_sendFile",
data: {
cid: "<target-conversation-id>",
mediaInfo: {
// buildFileRelationWithGroup 返回的白名单字段
fileId: "<file-id>",
parentId: "<parent-id>",
fileCardUrl: "<temporary-card-url>",
redirectFileUrl: "<temporary-resource-url>",
nodeName: "<original-file-name>",
nodeSize: 344838,
materialType: "zip",
md5: "<md5>",
thumbnailUrl: "<optional-thumbnail-url>",
downloadUrl: "<optional-download-url>",
},
baasMsgType: 107,
msgType: 53,
cardType: 12,
content: "<file-card-url>",
accountId: "<target-account-id>",
accountIdEncrypt: "<encrypted-target-account-id>",
bizType: string | null,
bizId: string | null,
clientVersion: string,
chatToken: "<session-token>",
},
callback(result) {
// 页面 UI 的本地受理回调,不等于最终 WebSocket sent 事实
},
}
```
`messageBox/sendMessage` 继续补充 scene、chatToken、clientInfo、extParams 等内部扩展字段,然后通过 SaaS adapter 调用:
```ts
window.IcbuIM.IMBaaSSDK.default.getMessageServiceV2().sendUIMessages(normalizedInput);
```
### 4.8 最终文件消息形状
本次目标历史确认到:
```ts
{
messageType: "send",
msgType: 10010,
subType: 61,
originalData: {
cardType: 12,
params: {
type: 12,
name: "<original-file-name>",
extensionType: "zip",
size: "344838", // raw 中为十进制字符串
id: "<file-id>",
parentId: "<parent-id>",
md5: "<md5>",
url: "<temporary-resource-url>",
thumbnailUrl: "<optional-thumbnail-url>",
downloadUrl: "", // 本次 raw 值为空
},
},
}
```
## 5. WebSocket 观测方法
### 5.1 观察目标
页面只旁路观察 OneTalk 主机:
```text
wss-icbu.dingtalk.com
```
observer 只处理:
```ts
{
code: 200,
body: [
{
singleChatUserConversation: {
lastMessage: {
message: { /* raw OneTalk message */ },
readStatus: number,
msgStatus: number,
},
singleChatConversation: {
pairFirst: string,
pairSecond: string,
},
},
},
],
}
```
`body.userMessageModels` 历史响应被明确忽略,由 SDK history adapter 单独负责,避免同一消息出现两个事实来源。
### 5.2 raw 文件识别
live 文件 raw content 至少满足:
```ts
{
contentType: 101,
custom: {
type: 10010,
data: "<base64-encoded-json>",
},
}
```
`custom.type=10010` 不能单独证明它是文件。解码后还必须满足:
```ts
{
cardType: 12,
params: {
id: string,
parentId: string,
name: string,
extensionType: string,
size: string,
md5?: string,
url?: string,
thumbnailUrl?: string,
downloadUrl?: string,
},
}
```
例如 `custom.type=10010 + cardType=2000` 是其它业务卡片,必须保持 unsupported,不能误判成附件。
### 5.3 MAIN-world 归一化
observer 在 MAIN world 完成 Base64、UTF-8、JSON 和字段校验,跨边界只发送归一化内容:
```ts
type OneTalkFileContent = {
version: 1;
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: "onetalk_session";
};
```
raw `params.size` 是十进制字符串,归一化后为安全整数 `sizeBytes`
本次 raw `downloadUrl` 为空,但 `params.url``fileAction=download`,因此归一化器把该 URL 派生为 `downloadUrl`,最终 `downloadState="available"`
## 6. 发送确认方法
### 6.1 不能作为最终确认的信号
以下信号只能说明阶段进展,不能单独生成 `confirmed_sent`
- `prepareSendFileWithGroup` HTTP 200。
- OSS 上传 HTTP 200。
- `buildFileRelationWithGroup` HTTP 200。
- 页面出现本地假消息或上传进度。
- 页面触发 `send-msg-success`
- `sendUIMessages` Promise resolve。
- 返回本地 clientId/opId,但尚未证明它与最终消息 ID 等价。
### 6.2 最终确认条件
只有 live observer 收到并完成完整消息校验的 sent 文件事实,才能确认:
```text
direction = sent
conversationId = pending target
content.kind = file
完整 OneTalkMessage guard 通过
```
若 SDK 返回可靠并可与 live 消息对应的候选消息 ID:
```text
conversationId + direction=sent + messageId
```
若没有可靠候选 ID,可使用复合文件指纹:
```text
conversationId
+ direction=sent
+ content.kind=file
+ fileName
+ extension
+ sizeBytes
+ md5(存在时)
+ 短发送时间窗口
```
可进一步加入 post-upload 的 `fileId/parentId`,但它们不能解决同一个云盘文件被重复发送的歧义。
### 6.3 建议 pending 模型
```ts
type PendingFileSend = {
kind: "file";
conversationId: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
expected: {
fileName: string;
extension: string;
sizeBytes: number;
md5?: string | null;
fileId?: string;
parentId?: string;
};
};
```
登记时机必须是:
```text
文件准备/上传/建关系完成
-> 得到最终 file metadata
-> 注册 PendingFileSend
-> 调用 sendUIMessages
```
原因是文件上传可能远超当前 correlator 的 10 秒超时。如果在用户选中文件时就注册 pending,大文件会在真正发送前超时。
### 6.4 重复与并发
同一文件重复发送时,以下字段都可能相同:
```text
fileName
extension
sizeBytes
md5
fileId
parentId
```
因此:
- 同一 live 消息若匹配多个 pending,所有相关请求都必须返回 `delivery_unknown/send_ambiguous`
- 不得按 pending 创建顺序、消息数组顺序或当前页面会话猜测归属。
- 如果产品必须支持同一文件并发发送,需要获得可回显的客户端关联 ID,或对同一会话/同一文件指纹实行显式串行化。
## 7. 验证方法
### 7.1 页面发送验证
1. 确认测试环境、目标会话和唯一选中会话。
2. 记录文件名、浏览器 MIME、大小;不读取或输出文件正文。
3. 在发送前启用 CDP Network、Runtime 和 WebSocket 帧计数。
4. 通过页面现有文件选择器设置文件。
5. 记录准备、OSS、建关系请求的 origin/path、方法、状态和耗时。
6. 只记录 WebSocket 帧数量和字节长度,不输出 payload。
7. 发送后调用 `fetchMessagesWithoutUpdateToRead` 读取目标会话一页历史。
8. 仅输出类型、字段存在性、大小和时间边界,不输出真实 ID 或 URL。
### 7.2 live observer 验证
通过扩展 Service Worker 只读查询:
```text
database: trade-message-center
store: onetalk_messages
store: onetalk_sync_candidates
```
匹配条件:
```text
目标 conversationId
+ direction=sent
+ content.kind=file
+ fileName/extension/sizeBytes
+ 本次发送时间窗口
```
确认候选记录的 `observationSource="live"`,即可排除“手工历史请求产生记录”的可能。历史 WebSocket response 本身被 observer 忽略,因此 `live` 来源代表普通 live push/echo。
### 7.3 本次运行结果
| 阶段 | 结果 |
| ------------------- | --------------------------------------------------------------------- |
| 页面文件选择 | 成功,文件名和大小一致 |
| 浏览器 MIME | `application/zip` |
| prepare API | HTTP 200 |
| OSS 二进制上传 | 未发生,命中去重分支 |
| build relation API | HTTP 200 |
| 页面成功事件 | 1 次 |
| WebSocket | 出站 7 帧,入站 7 帧,无异常 |
| 目标历史 | 唯一匹配的 sent 文件 1 条 |
| 历史类型 | `msgType=10010``subType=61``cardType=12` |
| 历史扩展类型 | `zip` |
| raw URL 状态 | `url``thumbnailUrl` 存在,显式 `downloadUrl` 为空 |
| live observer | 唯一匹配记录 1 条,`observationSource="live"` |
| normalized 内容 | `kind=file`、文件名/扩展/大小/MD5 完整 |
| normalized 下载状态 | `downloadUrl` 已派生,`downloadState=available` |
| candidate 状态 | `pending_ack`;说明 live 观察已完成,但本轮读取时 Bright ACK 尚未闭合 |
### 7.4 自动化验证
已执行:
```bash
node --experimental-strip-types --test \
apps/chrome-extension/test/onetalk-media-content-decoder.test.js \
apps/chrome-extension/test/onetalk-websocket-tap.test.js
```
结果:24 个测试全部通过,0 失败。
## 8. 文件与图片的相同点
| 维度 | 文件与图片共同机制 |
| ------------- | -------------------------------------------------------------- |
| 页面入口 | 都由隐藏文件选择器或等价 File 输入进入 |
| 上传接管 | 都由 OneTalk `beforeUpload` 接管,阻止通用 Upload 自动提交 |
| 身份 | 都需要目标会话的完整 contact/fromTo 上下文 |
| 内容准备 | 都计算 MD5,并调用云盘 prepare |
| 去重 | 服务端已有同 MD5 文件时都可以跳过 OSS 字节上传 |
| 上传 | 未命中去重时都使用 `policyDTO` 上传到动态 OSS host |
| 关系 | 都调用 `buildFileRelationWithGroup` 获取消息可用的媒体关系 |
| 页面消息入口 | 都经过 `sendFile -> messageBox/sendMessage -> sendUIMessages` |
| WebSocket | 最终都依赖 live sent 消息作为真实确认事实 |
| MAIN 安全边界 | raw Base64 只在 MAIN world 解码,下游只接收 normalized content |
| 确认优先级 | 可靠 message ID 优先;无 ID 时使用复合指纹与时间窗口 |
| 重复发送 | 同一资源重复/并发发送都会产生关联歧义,必须 fail closed |
| URL | 媒体 URL 均为会话范围临时引用,不适合作为稳定关联键 |
## 9. 文件与图片的不同点
| 维度 | 图片 | 普通文件 |
| ------------------ | -------------------------------- | ---------------------------------------------------- |
| 页面分类 | `imageCard` | `fileCard` |
| 页面兼容 `msgType` | `60` | `53` |
| BaaS 输入类型 | 图片类型,实测历史为 `102` | 页面输入默认 `107`,历史归一化为 `10010` |
| 历史 `subType` | `60` | `61` |
| raw content | `contentType=101/custom.type=7` | `contentType=101/custom.type=10010` |
| 二次判别 | 图片 payload schema | 必须同时满足 `cardType=12``10010` 本身不够 |
| 核心显示字段 | `width/height/isOriginal` | `fileName/parentId/downloadState` |
| 共同字段 | `fileId/extension/size/md5/url` | `fileId/extension/size/md5/url` |
| 大小类型 | raw `size` 为 number | raw `params.size` 为十进制 string |
| 压缩 | 约 1 MB 以上图片可能先压缩 | ZIP/PDF 等普通文件不做图片压缩 |
| 匹配指纹 | 大小 + 宽 + 高 + 可选 MD5/fileId | 文件名 + 扩展 + 大小 + 可选 MD5/fileId/parentId |
| URL 语义 | 主要是 image preview | 可能区分 office preview、download、thumbnail |
| 显式 downloadUrl | 图片合同没有下载状态 | 可为空;可由 `url.fileAction=download` 派生 |
| 文件真实性 | 可由图片解码进一步验证像素 | observer 无二进制,不能验证扩展名与 magic bytes 一致 |
## 10. 关键注意点
### 10.1 `custom.type=10010` 不等于文件
必须同时要求:
```text
contentType=101
+ custom.type=10010
+ Base64/UTF-8/JSON 解码成功
+ cardType=12
+ params 通过文件 schema
```
否则应进入 unsupported 或 anomaly,不能伪装为文件。
### 10.2 不要把乐观回执当作确认
`send-msg-success``sendUIMessages` resolve 只能证明页面本地受理。最终确认必须来自完整的 live `direction=sent` 消息。
### 10.3 URL 不可作为身份键
URL 可能包含会话授权、临时签名、重定向和不同 `fileAction`。不得:
- 在日志中输出完整 URL 或查询值。
- 用 URL 字符串相等确认发送。
- 在未验证生命周期前承诺 URL 可跨会话或长期持久化。
### 10.4 文件后缀只是元数据
本次文件名是 `.zip`,但文件签名为 PDF;OneTalk 最终仍生成 ZIP 文件卡片。这表明至少客户端路径主要依赖文件名/浏览器 MIME。由于命中去重,本次不能证明全新二进制上传时服务端是否执行内容检测。
### 10.5 文件确认需在上传之后开始
上传、分片、大文件策略和关系建立可能耗时较长。发送确认计时器应只覆盖最终消息发送阶段,而不是整个文件上传阶段。
### 10.6 当前代码能力边界
- 接收/观测合同已经支持 `content.kind="file"`
- 当前工作树中的出站合同正在扩展 `text | image`,尚未包含 `file`
- 当前 correlator 的文件 pending/matcher 尚未实现。
- 文件支持需要与图片支持共用一个判别联合和统一 pending 集合,不能另建第二套发送确认系统。
### 10.7 ACK 状态与页面发送是不同边界
本次 live 文件候选在读取时是 `pending_ack`
- 页面发送和 OneTalk 历史事实已经成立。
- live observer 也已收到并持久化。
- Bright 是否已接受该 observation,是下一层独立状态,不能由页面成功事件替代。
## 11. 可行性矩阵
| 能力 | 结论 | 证据/限制 |
| ------------------------ | ------------ | ---------------------------------- |
| 当前会话上传文件 | 已验证可行 | 本次测试成功 |
| 生成 `fileCard` | 已验证可行 | 历史 `10010/61/cardType=12` |
| live WS 观察文件 | 已验证可行 | IndexedDB `observationSource=live` |
| 按文件元数据回退确认 | 可行 | 必须复合匹配并处理歧义 |
| 仅按文件名确认 | 不可行 | 文件名可重复 |
| 仅按大小确认 | 不可行 | 冲突概率高 |
| 仅按 MD5/fileId 确认 | 不充分 | 同一文件重复发送会相同 |
| 跨当前会话定向文件发送 | 机制上可设计 | 本次文件样本未单独验证 |
| 真正 OSS 字节上传 | 本次未验证 | 命中去重分支 |
| 通过消息判断文件真实格式 | 不可行 | observer 没有二进制本体 |
| 扩展当前直接发送文件 | 尚未实现 | 当前出站合同只有 text/image |
## 12. 建议的不变量
后续如果实现文件发送,应维持以下不变量:
1. `cid` 是最终目标会话的权威路由字段,但上传前还必须取得该会话完整身份上下文。
2. 上传策略、Token、Cookie、原始 SDK payload 和完整 URL 不跨 MAIN 安全边界。
3. raw 文件只在 MAIN world 解码一次,下游统一消费 `OneTalkFileContent`
4. pending 文件指纹从最终 post-upload metadata 构造。
5. 候选消息 ID 优先于内容指纹。
6. 同一 live 消息匹配多个 pending 时 fail closed 为 `send_ambiguous`
7. SDK resolve、页面事件、网络 HTTP 200 都不能单独生成 `confirmed_sent`
8. 只有完整 `direction=sent` 的 OneTalkMessage 才能进入最终发送确认。
9. `extension` 表示 OneTalk 元数据,不表示已经验证二进制格式。
10. 文件发送应扩展现有发送合同和 correlator,不建立平行实现或第二事实源。