Files
trade-message-center/docs/onetalk-image-send-observation-feasibility.md

26 KiB
Raw Permalink Blame History

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. 结论

本次调查确认两件事:

  1. 可以在页面当前打开其它会话时,向显式指定的目标会话发送图片。 最终路由由发送参数中的 cid 决定,不要求切换页面 selected conversation。
  2. 可以在 WebSocket 观测阶段使用图片的已验证 canonical metadata 辅助确认发送结果。 v6 MAIN-world decoder 将 live WS 图片归一化为 content.kind="image",只保留 fileIdextensionsizeBytesisOriginalmd5previewUrlurlScope

sizeBytes 不是唯一键;即使 md5fileId 可用,重复发送同一文件时也可能相同。本次连续发送同一图片后,扩展存储中出现了两条不同的 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 所需的媒体关系,例如 fileIdfileCardUrlredirectFileUrl 和文件节点信息;若上游关系含图片尺寸,它们仍只属于 raw upload evidencev6 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 / heightMAIN 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;

md5fileId 可以提高不同图片之间的区分度,但同一文件去重或重复发送时它们也可能相同,仍不能当作每次发送的唯一 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 使用其剩余时间。

不能在用户选择原始文件时登记,原因有两个:

  1. 图片压缩可能改变最终 sizeBytes
  2. 上传时间可能超过该历史调查时 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 不匹配
sizeBytesmd5 或可用 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 联调

推荐的最小真实联调步骤:

  1. 打开会话 A,但指定目标会话 B。
  2. 记录 selected conversation 的内部比较结果,不输出真实 ID。
  3. 选择一张已知大小的测试图片。
  4. 对大图额外记录压缩后的最终 metadata。
  5. 在最终 sendUIMessages 前登记 image pending。
  6. 观察 prepare、OSS/去重、build relation 和 BaaS send 的状态。
  7. 捕获 live WS normalized message,并验证来源为 live
  8. 断言 selected conversation 未变化。
  9. 断言目标会话只读历史存在同一 sent 图片。
  10. 断言 correlator 返回完整 confirmed_sent,而不是仅凭页面 success event。

7.3 完成判据

只有同时满足以下条件,才算图片发送确认链闭合:

SDK send 已执行
+ live WS observer 输出完整 sent OneTalkMessage
+ conversationId 精确匹配目标会话
+ candidate ID 或媒体复合指纹唯一匹配
+ 完整消息通过 OneTalkMessage guard

以下证据不能单独生成 confirmed_sent

  • prepareSendFileWithGroup HTTP 200
  • OSS 上传成功;
  • buildFileRelationWithGroup HTTP 200
  • 页面出现图片或上传进度;
  • 页面 send-msg-success
  • SDK Promise resolve
  • 只看到 WebSocket 帧数量增加,但没有解析业务消息;
  • 历史接口稍后出现图片,但实时 correlator 没有观察到对应 live 消息。

历史查询适合作为联调和审计的独立验证,不应成为每次实时发送确认的必需轮询路径。

8. 注意点与风险

8.1 媒体 metadata 不唯一

同一图片重复发送会产生不同的 messageId,但 sizeBytesmd5fileId 可以完全相同。本次运行态已经得到两条这样的 live sent 记录。

不得采用:

找到第一条同尺寸图片 → confirmed_sent

8.2 压缩改变待比较字段

本次 I1 小于压缩阈值,最终消息大小与本地文件一致。大图会先压缩,必须使用压缩后、上传关系返回的实际 metadata。

8.3 URL 不能作为稳定关联键

媒体 URL 可能包含临时授权、重定向和会话作用域,上传前后也可能改变。previewUrlredirectFileUrlfileCardUrl 不适合作为首要关联键,更不能写入普通诊断日志。

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 / heightv6 MAIN decoder 忽略它们,correlator 的 post-upload expected 和 public/read payload 只使用无尺寸的 canonical contract。

9. 当前实现边界

当前实现限定为:

  1. 为页面图片发送定义独立、最小的输入合同;
  2. 在上传关系成功后生成 post-upload image fingerprint
  3. 使用 text/image/file 判别的 pending 集合;
  4. 复用现有 live WS observer 和 normalized content,不新增旁路;
  5. 候选 ID 优先,媒体指纹仅作无 ID 回退;
  6. 保留唯一匹配与 send_ambiguous
  7. 分离 upload timeout 与 send confirmation timeout
  8. 保持 SDK 异常和不确定投递 fail closed,不自动重试。

page-command.ts 现已严格接收 outbound text | image | file contract,并将 image 交给 sendOneTalkImagesend-observation.ts 先按 candidate message ID 确认,缺少候选 ID 时才以同会话、sent 方向、image kind、sizeBytesmd5、可用 fileId、时间窗和唯一性回退。当前自动化证据不代替未执行的 v6 Chromium live 验收。

10. 相关代码