26 KiB
OneTalk 非当前会话图片发送与 WebSocket 观测可行性报告
日期:2026-09-10 性质:测试环境运行态调查与实现可行性报告,不是 Trellis task,不包含代码实现 范围:本地图片发送到非当前打开会话,以及通过 WebSocket observer 确认 sent 图片事实
v6 同步说明(2026-09-11):本报告中的 raw upload、像素尺寸和当时 live 记录是历史调查证据;当前 canonical image、post-upload metadata、confirmation fingerprint 与 harness display 均不使用
width/height。未在本报告的旧运行环境复跑 v6 live 验收。
1. 结论
本次调查确认两件事:
- 可以在页面当前打开其它会话时,向显式指定的目标会话发送图片。 最终路由由发送参数中的
cid决定,不要求切换页面 selected conversation。 - 可以在 WebSocket 观测阶段使用图片的已验证 canonical metadata 辅助确认发送结果。 v6 MAIN-world decoder 将 live WS 图片归一化为
content.kind="image",只保留fileId、extension、sizeBytes、isOriginal、md5、previewUrl和urlScope。
但 sizeBytes 不是唯一键;即使 md5 和 fileId 可用,重复发送同一文件时也可能相同。本次连续发送同一图片后,扩展存储中出现了两条不同的 live sent 消息。因此 metadata 只能作为无 candidate ID 时的复合匹配条件,不能单独生成 confirmed_sent。
推荐确认顺序:
可靠候选 messageId
→ conversationId + direction=sent
→ content.kind=image
→ post-upload sizeBytes + md5 + optional fileId
→ 短时间窗口
→ 必须唯一匹配,否则 send_ambiguous
2. 调查范围与安全边界
本次使用已启动的 Chromium CDP 127.0.0.1:9222,目标页面为:
https://onetalk.alibaba.com/message/weblitePWA.htm
运行环境:
| 项目 | 结果 |
|---|---|
| CDP protocol | 1.3 |
| OneTalk host | seller_pwa |
| SaaS channel | true |
| GGS proxy | false |
| 活动 chat bundle | im-weblite-chat/1.0.700 |
| 页面 selected conversation | 恰好一个 |
| selected conversation 与目标会话 | 不同 |
| 发送过程中是否切换 selected conversation | 否 |
测试图片记为 I1:
格式:JPEG
文件大小:370256 bytes
像素尺寸:1192 × 1188
报告不记录以下内容:
- 真实目标会话 ID;
- 登录账号、联系人及加密身份值;
- Cookie、token、请求头和 POST body;
- MD5 实值、fileId、messageId;
- 完整媒体 URL 及 URL query。
3. 本次运行态证据
3.1 非当前会话定向发送
发送前确认:
- 当前 selected conversation 与目标会话不同;
- 目标会话在
window.__conversationListFullData__中唯一存在; - 文件选择器中保留一个与
I1对应的File; - 页面目标组件具备
sendFileToOss; - 目标是单聊会话。
执行时显式向 sendFileToOss 传入目标会话对象,没有修改页面 selected conversation。
运行结果:
| 证据 | 结果 |
|---|---|
prepareSendFileWithGroup |
HTTP 200 |
buildFileRelationWithGroup |
HTTP 200 |
| OSS 二进制上传 | 未发生,命中文件已存在/去重分支 |
BaaS sendMessageBase |
已进入一次 |
页面 send-msg-success |
一次 |
| WebSocket 帧 | 出站 3、入站 3 |
| Runtime exception | 0 |
| selected conversation | 全程不变 |
| 目标会话只读历史 | 找到一条与 I1 大小一致的 sent 图片 |
页面 send-msg-success 和 SDK Promise 只证明本地受理,不能单独证明发送完成。目标会话只读历史中的 sent 图片事实才排除了“只插入了页面假消息”的情况。
3.2 live WebSocket 归一化证据
扩展 Service Worker 的 IndexedDB 中,对目标会话和 I1 的归一化字段进行只读匹配:
| 项目 | 结果 |
|---|---|
| exact image candidate | 3 条 |
observationSource="live" |
2 条 |
observationSource="history" |
1 条 |
| candidate status | 全部 confirmed |
当时 live 记录的 raw sizeBytes/width/height |
与 I1 完全一致 |
手工调用的只读历史接口没有把返回值送入页面 bridge 或 Service Worker;同时 raw WebSocket history response 会被 observer 主动忽略。因此 observationSource="live" 的两条记录证明当时的 live 观察链已能接收图片事实。该记录来自 v6 前的调查,不构成对当前 v6 normalized shape 的运行态验证:当前 MAIN decoder 忽略 raw width / height,它们不进入归一化存储边界。
这两条 live 消息也构成反例:相同图片重复发送时,媒体 metadata 可以相同,但它们仍是不同的消息事实。
4. 图片发送路径与参数
4.1 页面普通本地图片路径
OneTalk 页面普通图片发送链路为:
隐藏的 input[type=file][multiple]
→ FileList 转成 File[],为每个 File 分配 uid
→ Upload.beforeUpload 返回 false,阻止通用 Upload 自动请求
→ OneTalk beforeUpload(File) 接管
→ 文件校验、类型判断、本地 preview URL
→ 适用时压缩图片
→ 计算文件 MD5
→ prepareSendFileWithGroup
→ OSS 上传,或命中文件已存在后跳过上传
→ buildFileRelationWithGroup
→ sendFile
→ messageBox/sendMessage
→ getMessageServiceV2().sendUIMessages(...)
→ PaaS send / live WebSocket echo
页面 UI 还会用 tmpKey 插入本地假消息和上传进度。假消息只属于展示层,不是 sent 消息事实。
4.2 文件输入参数
文件选择阶段的核心对象是浏览器 File:
type UploadImageFile = File & {
uid: string;
};
业务流程实际使用的字段包括:
file.name
file.type
file.size
file.uid
图片大于等于约 1_000_000 bytes 时,页面会尝试压缩。压缩后的 File 才进入后续上传,因此原始 file.size 不一定等于最终消息中的 originalData.size。
4.3 目标会话参数
本地图片上传不只需要 cid。建立云盘文件关系和构造消息还需要目标会话的完整页面模型,至少涉及:
type TargetConversationContext = {
cid: string;
conversationType: number;
accountId: string | number;
accountIdEncrypt?: string;
aliId?: string | number;
aliIdEncrypt?: string;
chatToken?: string;
contact?: {
aliId?: string | number;
};
owner?: {
accountId?: string | number;
aliId?: string | number;
};
};
这些字段只应留在 OneTalk MAIN world 内使用,不应作为新的 Bright 或 Mind 原始合同整体向外传递。
文件关系使用的发送双方参数形态为:
type FileRelationParticipants = {
from: string | number;
to: string | number;
fromAliId: string | number;
toAliId: string | number;
};
本次定向发送传给页面内部上传函数的参数等价于:
sendFileToOss({
file,
fromTo: {
from: currentUserAccountId,
to: targetConversation.accountId,
fromAliId: targetConversation.owner?.aliId,
toAliId: targetConversation.contact?.aliId ?? targetConversation.aliId,
},
tmpKey,
contact: targetConversation,
previewUrl,
traceId,
});
4.4 准备上传参数
页面通过 MTop 调用:
mtop.alibaba.interaction.clouddisk.prepareSendFileWithGroup
核心业务参数:
{
appKey: "OneChat",
fileName: `${md5}.${extension}`,
scene: JSON.stringify({
sceneType: isGroupChat ? "2" : "1",
idType: "2",
from: fromAliId,
to: toAliId,
}),
isToken: false,
}
较大文件存在单独的 token/上传分支,不能假设所有文件始终使用完全相同的 prepare 参数。
prepare 成功后会返回上传目录、最大文件限制、文件是否已存在和 OSS policy。报告只记录这些字段是否存在,不记录其值。
4.5 OSS 上传参数
未命中去重时,页面向 prepare 返回的动态 OSS host 上传:
{
file,
filename,
action: policyDTO.host,
data: {
key,
policy,
OSSAccessKeyId,
success_action_status: "200",
callback,
signature,
},
onProgress,
}
本次 I1 命中文件已存在分支,因此没有实际发生 OSS 二进制 POST;这不影响后续文件关系建立和消息发送。
4.6 建立文件关系参数
页面随后调用:
mtop.alibaba.interaction.clouddisk.buildFileRelationWithGroup
概念参数为:
{
appKey: "OneChat",
scene: {
sceneType,
idType,
from,
to,
},
node: {
nodeName,
nodeSize,
materialType,
md5,
bucket,
extend,
},
params: {
source: "",
needCardUrl: true,
},
}
成功结果提供后续 sendFile 所需的媒体关系,例如 fileId、fileCardUrl、redirectFileUrl 和文件节点信息;若上游关系含图片尺寸,它们仍只属于 raw upload evidence,v6 downstream 不读取。
4.7 最终页面发送参数
图片扩展名被归类为 imageCard。页面的 sendFile 构造:
const data = {
cid: targetConversation.cid,
mediaInfo: {
...relationInfo,
nodeName,
},
baasMsgType: 102,
content: relationInfo.fileCardUrl,
msgType: 60,
accountId: targetConversation.accountId,
accountIdEncrypt: targetConversation.accountIdEncrypt,
bizType,
bizId,
clientVersion,
chatToken: targetConversation.chatToken,
};
sendMessage({
sendType: "file",
tmpKey,
data,
traceId,
contentType: "imageCard",
scene: "_sendFile",
callback,
});
messageBox/sendMessage 会补齐页面 scene、ext、client 信息和必要身份字段,当前 SaaS 分支最终调用:
window.IcbuIM.IMBaaSSDK.default.getMessageServiceV2().sendUIMessages(data);
V2 转换器显式以 cid 选择会话:
conversationCode = input.cid || sdkContext.cid;
因此只要传入非空目标 cid,当前页面 selected conversation 不参与最终路由;缺失 cid 时才会回退 SDK 当前上下文,存在发错会话风险。
图片会被转换为 BaaS raw originalData:
{
height,
width,
url: redirectFileUrl,
isOriginal: 1,
size: nodeSize ?? size,
md5,
fileId,
suffix: materialType,
}
这是页面上游的 raw upload payload 示例,不是 v6 canonical contract。即使该 raw payload 含 width / height,MAIN decoder 也会忽略它们,且 post-upload metadata、correlator 和下游 public content 都不会读取或传递这两个字段。
4.8 sendImageMessage 快捷入口
页面 SDK 还暴露:
messageService.sendImageMessage({
cid: targetConversationId,
picUrl: [uploadedImageUrl],
});
它对应 SDK command:
im.singlemsg.sendImageMessage
该入口适用于已经拥有可用图片 URL 的场景。它不能直接接收本地 File;本次本地文件定向发送验证走的是 sendFileToOss → sendFile → sendUIMessages,没有把 sendImageMessage 快捷入口冒充为已验证的本地上传路径。
5. WebSocket 观测方法
5.1 观测入口
扩展 MAIN-world observer 只旁路观察:
wss-icbu.dingtalk.com
入口实现位于:
处理顺序:
WebSocket message event
→ 跳过 heartbeat
→ JSON.parse
→ 要求 frame.code === 200
→ 忽略 body.userMessageModels 历史响应
→ 仅处理 live body[]
→ singleChatUserConversation.lastMessage.message
→ 验证消息身份和 sent/received 方向
→ 解码 content
→ 输出 ObservedOneTalkMessage
MessagePack /s/sync push 当前不会被当作普通 live 媒体消息处理,因此不能将 JSON live 路径的结论直接外推到所有 WebSocket frame。
5.2 图片内容解码
live 图片的 raw 内容形态:
{
contentType: 101,
custom: {
type: 7,
data: "<Base64 JSON>",
},
}
content-decoder.ts 在 MAIN world 执行:
Base64
→ UTF-8
→ JSON.parse
→ exact field validation
→ normalized image content
输出合同定义于 content.ts:
type OneTalkImageContent = {
version: 1;
kind: "image";
fileId: string;
extension: string;
sizeBytes: number;
isOriginal: boolean;
md5: string | null;
previewUrl: string | null;
urlScope: "onetalk_session";
};
send correlator 不需要也不应该继续读取 raw originalData。应比较已经验证、清洗后的:
message.content.sizeBytes
message.content.md5
message.content.fileId (when present)
5.3 observer 与 correlator 的调用顺序
page-script-entry.ts 当前顺序是:
sendObservation.observe(batch.messages);
observedSink(batch);
因此图片 live batch 在跨 MAIN bridge、写 IndexedDB 或上传 Bright 之前,已经可以交给发送确认 correlator。无需新增第二个 WebSocket observer,也不应增加另一套 raw payload parser。
6. 当前 v6 图片确认模型
6.1 Pending 数据
v6 的 send-observation.ts 已使用 text/image/file 判别联合;图片 pending 保存目标会话、候选消息 ID 与最终 post-upload metadata,而不是给文本记录追加可选字段。下列 image 分支是当前实现的形状:
type PendingTextSend = {
kind: "text";
conversationId: string;
content: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
};
type PendingImageSend = {
kind: "image";
conversationId: string;
sentAfterMs: number;
candidateMessageIds: Set<string>;
expected: {
sizeBytes: number;
md5: string;
fileId?: string;
};
};
type PendingObservation = PendingTextSend | PendingImageSend | PendingFileSend;
md5 和 fileId 可以提高不同图片之间的区分度,但同一文件去重或重复发送时它们也可能相同,仍不能当作每次发送的唯一 ID。
6.2 登记时机
图片 pending 在取得最终 media relation 后、最终 native send 前登记:
压缩完成
→ 上传/去重完成
→ buildFileRelationWithGroup
→ 得到最终 mediaInfo
→ 登记 PendingImageSend
→ 立即调用 sendUIMessages
实现以 tmpKey 隔离上传回调;在 buildFileRelationWithGroup 成功、sendFile 已拿到最终 sizeBytes、非空 md5 和可用 fileId 后,立即将该 metadata 交给 correlator 并调用最终 SDK send。45 秒预算覆盖下载、上传和 live 确认,correlator 使用其剩余时间。
不能在用户选择原始文件时登记,原因有两个:
- 图片压缩可能改变最终
sizeBytes; - 上传时间可能超过该历史调查时 correlator 的
10_000ms超时;当前 image 预算为 45 秒。
上传阶段和消息发送确认阶段应是两个状态,不要让消息确认定时器覆盖完整上传耗时。
6.3 匹配顺序
已实现的匹配顺序:
1. message.direction 必须是 sent
2. message.conversationId 必须等于 pending.conversationId
3. message 必须通过完整 OneTalkMessage guard
4. 如果存在可靠 candidateMessageId:只按 messageId 匹配,不回退媒体指纹
5. 否则要求 pending.kind=image 且 message.content.kind=image
6. 比较 post-upload `sizeBytes`、`md5` 和可用 `fileId`
7. 这些 metadata 不能被当作单次发送唯一键
8. 要求消息位于 pending 生命周期和允许的时钟偏差内
9. 一个消息必须只匹配一个 pending;多个匹配立即 send_ambiguous
概念判断:
const imageMatches = (
expected: PendingImageSend["expected"],
actual: OneTalkImageContent,
): boolean =>
actual.sizeBytes === expected.sizeBytes &&
actual.md5 === expected.md5 &&
(expected.fileId === undefined || actual.fileId === expected.fileId);
该函数只能是复合匹配的一部分,不能绕过 conversation、direction、时间窗口和唯一性检查。
7. 验证方法
7.1 自动化测试
至少需要补充以下测试:
| 用例 | 预期 |
|---|---|
| live WS 图片 raw payload | 忽略 raw width / height;输出无尺寸的 v6 canonical image |
| 正确会话、方向、指纹和时间 | confirmed_sent |
| 错误 conversation | 不匹配 |
direction=received |
不匹配 |
sizeBytes、md5 或可用 fileId 不同 |
不匹配 |
| 候选 message ID 匹配 | 即使时间窗口外仍按 ID 确认 |
| 候选 message ID 不匹配 | 不回退图片指纹 |
| 同图两个并发 pending | send_ambiguous |
| 图片压缩后大小变化 | 使用 post-upload 大小确认 |
| 超时无 live echo | delivery_unknown/send_timeout |
| 非法 Base64/JSON/schema | anomaly,不进入 correlator |
定向测试覆盖 raw 图片解码、flat history 图片归一化、非法 live 媒体隔离,以及无尺寸 live sent image 驱动实际 image pending 至 confirmed_sent。这只是自动化证据;尚未在本报告原有 Chromium 环境执行 v6 真实发送联调。
7.2 Chromium/CDP 联调
推荐的最小真实联调步骤:
- 打开会话 A,但指定目标会话 B。
- 记录 selected conversation 的内部比较结果,不输出真实 ID。
- 选择一张已知大小的测试图片。
- 对大图额外记录压缩后的最终 metadata。
- 在最终
sendUIMessages前登记 image pending。 - 观察 prepare、OSS/去重、build relation 和 BaaS send 的状态。
- 捕获 live WS normalized message,并验证来源为
live。 - 断言 selected conversation 未变化。
- 断言目标会话只读历史存在同一 sent 图片。
- 断言 correlator 返回完整
confirmed_sent,而不是仅凭页面 success event。
7.3 完成判据
只有同时满足以下条件,才算图片发送确认链闭合:
SDK send 已执行
+ live WS observer 输出完整 sent OneTalkMessage
+ conversationId 精确匹配目标会话
+ candidate ID 或媒体复合指纹唯一匹配
+ 完整消息通过 OneTalkMessage guard
以下证据不能单独生成 confirmed_sent:
prepareSendFileWithGroupHTTP 200;- OSS 上传成功;
buildFileRelationWithGroupHTTP 200;- 页面出现图片或上传进度;
- 页面
send-msg-success; - SDK Promise resolve;
- 只看到 WebSocket 帧数量增加,但没有解析业务消息;
- 历史接口稍后出现图片,但实时 correlator 没有观察到对应 live 消息。
历史查询适合作为联调和审计的独立验证,不应成为每次实时发送确认的必需轮询路径。
8. 注意点与风险
8.1 媒体 metadata 不唯一
同一图片重复发送会产生不同的 messageId,但 sizeBytes、md5 和 fileId 可以完全相同。本次运行态已经得到两条这样的 live sent 记录。
不得采用:
找到第一条同尺寸图片 → confirmed_sent
8.2 压缩改变待比较字段
本次 I1 小于压缩阈值,最终消息大小与本地文件一致。大图会先压缩,必须使用压缩后、上传关系返回的实际 metadata。
8.3 URL 不能作为稳定关联键
媒体 URL 可能包含临时授权、重定向和会话作用域,上传前后也可能改变。previewUrl、redirectFileUrl、fileCardUrl 不适合作为首要关联键,更不能写入普通诊断日志。
8.4 MD5 和 fileId 也不是每次发送唯一键
它们能区分多数不同文件,但文件去重意味着重复发送同一图片时可能继续相同。真正可靠的候选 messageId 应优先于所有媒体指纹。
8.5 本地受理不等于真实发送
V2 sendUIMessages 的 Promise 可以在 local callback 得到 clientId/opId 后 resolve。后续 PaaS change 和 live sent message 才能证明消息事实。
不能把 clientId/opId 不经验证地当成最终 messageId。如果需要以候选 ID 为主键,必须先验证它与 live messageId 的稳定映射。
8.6 目标会话上下文要求
本次目标会话已经存在于 window.__conversationListFullData__,因此能够取得文件关系所需的双方身份、账号和 chat token。只有一个任意 cid、但没有完整目标会话上下文时,本地文件上传路径尚未验证为安全可用。
不应从会话 ID 字符串、participant 顺序或当前页面联系人猜测缺失身份字段。
8.7 群聊未验证
页面上传代码存在 group scene 分支,但本次仅验证单聊。群聊 participant、关系建立和 live message envelope 需要独立验证。
8.8 不确定发送禁止自动重试
上传完成后如果 SDK 连接丢失、live echo 缺失或多个 pending 发生歧义,应返回 delivery_unknown,不得自动重发图片,以免产生重复消息。
8.9 保持单一解析边界
raw originalData 只应在 MAIN world 短暂存在。send correlator 应消费已经归一化的 OneTalkImageContent,不要在 correlator、Service Worker 或 Bright 中再实现第二套 Base64/JSON 图片解析。
上游 raw originalData 可含 width / height;v6 MAIN decoder 忽略它们,correlator 的 post-upload expected 和 public/read payload 只使用无尺寸的 canonical contract。
9. 当前实现边界
当前实现限定为:
- 为页面图片发送定义独立、最小的输入合同;
- 在上传关系成功后生成 post-upload image fingerprint;
- 使用 text/image/file 判别的 pending 集合;
- 复用现有 live WS observer 和 normalized content,不新增旁路;
- 候选 ID 优先,媒体指纹仅作无 ID 回退;
- 保留唯一匹配与
send_ambiguous; - 分离 upload timeout 与 send confirmation timeout;
- 保持 SDK 异常和不确定投递 fail closed,不自动重试。
page-command.ts 现已严格接收 outbound text | image | file contract,并将 image 交给 sendOneTalkImage;send-observation.ts 先按 candidate message ID 确认,缺少候选 ID 时才以同会话、sent 方向、image kind、sizeBytes、md5、可用 fileId、时间窗和唯一性回退。当前自动化证据不代替未执行的 v6 Chromium live 验收。
10. 相关代码
- 页面 WebSocket tap:
websocket.ts - live frame 分发:
index.ts - live message 解析:
new.ts - MAIN 内容归一化:
content-decoder.ts - 归一化媒体合同:
content.ts - observer/correlator 接线:
page-script-entry.ts - 当前文本发送确认:
send-observation.ts - 当前页面命令入口:
page-command.ts - 媒体解码测试:
onetalk-media-content-decoder.test.js - WebSocket observer 测试:
onetalk-websocket-tap.test.js