26 KiB
OneTalk 文件发送与 WebSocket 观测可行性报告
日期:2026-09-10
环境:OneTalk SaaS 测试环境,Chromium CDP127.0.0.1:9222
性质:运行态调查与可行性结论,不包含代码实现
隐私约束:本文不记录真实会话 ID、账号 ID、Token、Cookie、媒体完整 URL、URL 查询值或 MD5 原值历史快照说明(2026-09-11):下文的“当前”“已实现”和能力结论均指 2026-09-10 的调查环境,不构成当前工作树的 file-send 发布承诺。本轮 v6 只完成并检查了图片无尺寸合同;文件发送、其 pending/matcher 与真实 Chromium 联调须在独立范围按当前代码重新验证。
1. 结论摘要
OneTalk 文件发送和实时确认均可实现,但需要把“上传完成”和“消息发送确认”分成两个阶段:
- 本地
File先经过校验、MD5、云盘准备、可选 OSS 上传和文件关系建立。 - 页面将关系结果转换为
fileCard,最终调用getMessageServiceV2().sendUIMessages(...)。 - OneTalk 服务端通过 live WebSocket 回送完整 sent 文件消息。
- 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 总体流程
<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 自己的上传流程。
页面从当前会话上下文构造:
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 云盘准备接口
页面调用:
GET https://acs.h.alibaba.com/h5/
mtop.alibaba.interaction.clouddisk.preparesendfilewithgroup/1.0/
概念参数结构:
{
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",
}
响应中的关键业务字段包括:
{
fileIsExist: boolean;
uploadFileDir: string;
allowSendFileMaxSize: number;
policyDTO: {
host: string;
encodedPolicy: string;
accessid: string;
callbackBody: string;
signature: string;
}
}
这些策略值均属于运行时上传凭证,不得穿过页面边界、写入日志或持久化。
4.5 OSS 上传
仅当 fileIsExist !== true 时,页面才会向 policyDTO.host 发起文件上传。概念请求为:
{
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 建立文件关系
页面调用:
GET https://acs.h.alibaba.com/h5/
mtop.alibaba.interaction.clouddisk.buildfilerelationwithgroup/1.0/
概念参数结构:
{
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",
}
成功结果向后续发送阶段提供:
fileId / id
parentId
fileCardUrl
redirectFileUrl
nodeName
nodeSize
materialType
md5
thumbnailUrl
downloadUrl
traceId
4.7 构造 fileCard 并发送
sendFile 根据 materialType 分类:
图片扩展 -> imageCard
视频扩展 -> videoCard
其它扩展 -> fileCard
ZIP 进入 fileCard 分支。发送给 messageBox/sendMessage 的概念结构是:
{
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 调用:
window.IcbuIM.IMBaaSSDK.default.getMessageServiceV2().sendUIMessages(normalizedInput);
4.8 最终文件消息形状
本次目标历史确认到:
{
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 主机:
wss-icbu.dingtalk.com
observer 只处理:
{
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 至少满足:
{
contentType: 101,
custom: {
type: 10010,
data: "<base64-encoded-json>",
},
}
custom.type=10010 不能单独证明它是文件。解码后还必须满足:
{
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 和字段校验,跨边界只发送归一化内容:
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:
prepareSendFileWithGroupHTTP 200。- OSS 上传 HTTP 200。
buildFileRelationWithGroupHTTP 200。- 页面出现本地假消息或上传进度。
- 页面触发
send-msg-success。 sendUIMessagesPromise resolve。- 返回本地 clientId/opId,但尚未证明它与最终消息 ID 等价。
6.2 最终确认条件
只有 live observer 收到并完成完整消息校验的 sent 文件事实,才能确认:
direction = sent
conversationId = pending target
content.kind = file
完整 OneTalkMessage guard 通过
若 SDK 返回可靠并可与 live 消息对应的候选消息 ID:
conversationId + direction=sent + messageId
若没有可靠候选 ID,可使用复合文件指纹:
conversationId
+ direction=sent
+ content.kind=file
+ fileName
+ extension
+ sizeBytes
+ md5(存在时)
+ 短发送时间窗口
可进一步加入 post-upload 的 fileId/parentId,但它们不能解决同一个云盘文件被重复发送的歧义。
6.3 建议 pending 模型
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;
};
};
登记时机必须是:
文件准备/上传/建关系完成
-> 得到最终 file metadata
-> 注册 PendingFileSend
-> 调用 sendUIMessages
原因是文件上传可能远超当前 correlator 的 10 秒超时。如果在用户选中文件时就注册 pending,大文件会在真正发送前超时。
6.4 重复与并发
同一文件重复发送时,以下字段都可能相同:
fileName
extension
sizeBytes
md5
fileId
parentId
因此:
- 同一 live 消息若匹配多个 pending,所有相关请求都必须返回
delivery_unknown/send_ambiguous。 - 不得按 pending 创建顺序、消息数组顺序或当前页面会话猜测归属。
- 如果产品必须支持同一文件并发发送,需要获得可回显的客户端关联 ID,或对同一会话/同一文件指纹实行显式串行化。
7. 验证方法
7.1 页面发送验证
- 确认测试环境、目标会话和唯一选中会话。
- 记录文件名、浏览器 MIME、大小;不读取或输出文件正文。
- 在发送前启用 CDP Network、Runtime 和 WebSocket 帧计数。
- 通过页面现有文件选择器设置文件。
- 记录准备、OSS、建关系请求的 origin/path、方法、状态和耗时。
- 只记录 WebSocket 帧数量和字节长度,不输出 payload。
- 发送后调用
fetchMessagesWithoutUpdateToRead读取目标会话一页历史。 - 仅输出类型、字段存在性、大小和时间边界,不输出真实 ID 或 URL。
7.2 live observer 验证
通过扩展 Service Worker 只读查询:
database: trade-message-center
store: onetalk_messages
store: onetalk_sync_candidates
匹配条件:
目标 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 自动化验证
已执行:
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 本身不够 |
| 核心显示字段 | extension/sizeBytes/isOriginal |
fileName/parentId/downloadState |
| 共同字段 | fileId/extension/sizeBytes/md5/previewUrl/urlScope |
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 派生 |
| 文件真实性 | 仅验证 OneTalk canonical metadata | observer 无二进制,不能验证扩展名与 magic bytes 一致 |
图片 raw payload 仍可能携带 width / height,但它们只属于 OneTalk 上游证据:v6 MAIN decoder 忽略这两个字段,normalized/public image、post-upload metadata、confirmation fingerprint 与 harness display 均不读取或显示它们。
10. 关键注意点
10.1 custom.type=10010 不等于文件
必须同时要求:
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 2026-09-10 调查时的代码能力边界
- 接收/观测合同已经支持
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. 建议的不变量
后续如果实现文件发送,应维持以下不变量:
cid是最终目标会话的权威路由字段,但上传前还必须取得该会话完整身份上下文。- 上传策略、Token、Cookie、原始 SDK payload 和完整 URL 不跨 MAIN 安全边界。
- raw 文件只在 MAIN world 解码一次,下游统一消费
OneTalkFileContent。 - pending 文件指纹从最终 post-upload metadata 构造。
- 候选消息 ID 优先于内容指纹。
- 同一 live 消息匹配多个 pending 时 fail closed 为
send_ambiguous。 - SDK resolve、页面事件、网络 HTTP 200 都不能单独生成
confirmed_sent。 - 只有完整
direction=sent的 OneTalkMessage 才能进入最终发送确认。 extension表示 OneTalk 元数据,不表示已经验证二进制格式。- 文件发送应扩展现有发送合同和 correlator,不建立平行实现或第二事实源。