Files
trade-message-center/docs/onetalk-file-send-observation-feasibility-2026-09-10.md
T

26 KiB
Raw Blame History

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

日期:2026-09-10
环境:OneTalk SaaS 测试环境,Chromium CDP 127.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 文件发送和实时确认均可实现,但需要把“上传完成”和“消息发送确认”分成两个阶段:

  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 总体流程

<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.urlfileAction=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 文件事实,才能确认:

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 页面发送验证

  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 只读查询:

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=10010subType=61cardType=12
历史扩展类型 zip
raw URL 状态 urlthumbnailUrl 存在,显式 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=1210010 本身不够
核心显示字段 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-successsendUIMessages 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. 建议的不变量

后续如果实现文件发送,应维持以下不变量:

  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,不建立平行实现或第二事实源。