docs(onetalk): record image send observation feasibility

This commit is contained in:
YBF
2026-09-10 12:51:21 +08:00
parent 547980d530
commit acfe509833
@@ -0,0 +1,694 @@
# OneTalk 非当前会话图片发送与 WebSocket 观测可行性报告
> 日期:2026-09-10
> 性质:测试环境运行态调查与实现可行性报告,不是 Trellis task,不包含代码实现
> 范围:本地图片发送到非当前打开会话,以及通过 WebSocket observer 确认 sent 图片事实
## 1. 结论
本次调查确认两件事:
1. **可以在页面当前打开其它会话时,向显式指定的目标会话发送图片。** 最终路由由发送参数中的 `cid` 决定,不要求切换页面 selected conversation。
2. **可以在 WebSocket 观测阶段使用图片的大小、宽度和高度辅助确认发送结果。** live WS 图片会被 MAIN-world 解码器归一化为 `content.kind="image"`,并保留 `sizeBytes``width``height`
`sizeBytes + width + height` 不是唯一键。本次连续发送同一图片后,扩展存储中出现了两条不同的 live sent 消息,它们的三个字段完全相同。因此该三元组只能作为复合匹配条件,不能单独生成 `confirmed_sent`
推荐确认顺序:
```text
可靠候选 messageId
→ conversationId + direction=sent
→ content.kind=image
→ post-upload sizeBytes + width + height
→ 短时间窗口
→ 必须唯一匹配,否则 send_ambiguous
```
## 2. 调查范围与安全边界
本次使用已启动的 Chromium CDP `127.0.0.1:9222`,目标页面为:
```text
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`
```text
格式: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 记录的 `sizeBytes/width/height` | 与 `I1` 完全一致 |
手工调用的只读历史接口没有把返回值送入页面 bridge 或 Service Worker;同时 raw WebSocket history response 会被 observer 主动忽略。因此 `observationSource="live"` 的两条记录证明,图片大小和宽高确实能经过 live WS observer 到达归一化存储边界。
这两条 live 消息也构成反例:相同图片重复发送时,大小和宽高完全相同,但它们是不同的消息事实。
## 4. 图片发送路径与参数
### 4.1 页面普通本地图片路径
OneTalk 页面普通图片发送链路为:
```text
隐藏的 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`
```ts
type UploadImageFile = File & {
uid: string;
};
```
业务流程实际使用的字段包括:
```text
file.name
file.type
file.size
file.uid
```
图片大于等于约 `1_000_000` bytes 时,页面会尝试压缩。压缩后的 `File` 才进入后续上传,因此原始 `file.size` 不一定等于最终消息中的 `originalData.size`
### 4.3 目标会话参数
本地图片上传不只需要 `cid`。建立云盘文件关系和构造消息还需要目标会话的完整页面模型,至少涉及:
```ts
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 原始合同整体向外传递。
文件关系使用的发送双方参数形态为:
```ts
type FileRelationParticipants = {
from: string | number;
to: string | number;
fromAliId: string | number;
toAliId: string | number;
};
```
本次定向发送传给页面内部上传函数的参数等价于:
```ts
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 调用:
```text
mtop.alibaba.interaction.clouddisk.prepareSendFileWithGroup
```
核心业务参数:
```ts
{
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 上传:
```ts
{
file,
filename,
action: policyDTO.host,
data: {
key,
policy,
OSSAccessKeyId,
success_action_status: "200",
callback,
signature,
},
onProgress,
}
```
本次 `I1` 命中文件已存在分支,因此没有实际发生 OSS 二进制 POST;这不影响后续文件关系建立和消息发送。
### 4.6 建立文件关系参数
页面随后调用:
```text
mtop.alibaba.interaction.clouddisk.buildFileRelationWithGroup
```
概念参数为:
```ts
{
appKey: "OneChat",
scene: {
sceneType,
idType,
from,
to,
},
node: {
nodeName,
nodeSize,
materialType,
md5,
bucket,
extend,
},
params: {
source: "",
needCardUrl: true,
},
}
```
成功结果提供后续 `sendFile` 所需的媒体关系,例如 `fileId``fileCardUrl``redirectFileUrl`、图片尺寸和文件节点信息。
### 4.7 最终页面发送参数
图片扩展名被归类为 `imageCard`。页面的 `sendFile` 构造:
```ts
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 分支最终调用:
```ts
window.IcbuIM.IMBaaSSDK.default.getMessageServiceV2().sendUIMessages(data);
```
V2 转换器显式以 `cid` 选择会话:
```ts
conversationCode = input.cid || sdkContext.cid;
```
因此只要传入非空目标 `cid`,当前页面 selected conversation 不参与最终路由;缺失 `cid` 时才会回退 SDK 当前上下文,存在发错会话风险。
图片被转换为 BaaS `originalData`
```ts
{
height,
width,
url: redirectFileUrl,
isOriginal: 1,
size: nodeSize ?? size,
md5,
fileId,
suffix: materialType,
}
```
### 4.8 `sendImageMessage` 快捷入口
页面 SDK 还暴露:
```ts
messageService.sendImageMessage({
cid: targetConversationId,
picUrl: [uploadedImageUrl],
});
```
它对应 SDK command
```text
im.singlemsg.sendImageMessage
```
该入口适用于已经拥有可用图片 URL 的场景。它不能直接接收本地 `File`;本次本地文件定向发送验证走的是 `sendFileToOss → sendFile → sendUIMessages`,没有把 `sendImageMessage` 快捷入口冒充为已验证的本地上传路径。
## 5. WebSocket 观测方法
### 5.1 观测入口
扩展 MAIN-world observer 只旁路观察:
```text
wss-icbu.dingtalk.com
```
入口实现位于:
- [`websocket.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts)
- [`index.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts)
- [`new.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts)
处理顺序:
```text
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 内容形态:
```ts
{
contentType: 101,
custom: {
type: 7,
data: "<Base64 JSON>",
},
}
```
[`content-decoder.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts) 在 MAIN world 执行:
```text
Base64
→ UTF-8
→ JSON.parse
→ exact field validation
→ normalized image content
```
输出合同定义于 [`content.ts`](../apps/onetalk-contract/src/content.ts)
```ts
type OneTalkImageContent = {
version: 1;
kind: "image";
fileId: string;
extension: string;
sizeBytes: number;
width: number;
height: number;
isOriginal: boolean;
md5: string | null;
previewUrl: string | null;
urlScope: "onetalk_session";
};
```
send correlator 不需要也不应该继续读取 raw `originalData`。应比较已经验证、清洗后的:
```text
message.content.sizeBytes
message.content.width
message.content.height
```
### 5.3 observer 与 correlator 的调用顺序
[`page-script-entry.ts`](../apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts) 当前顺序是:
```ts
sendObservation.observe(batch.messages);
observedSink(batch);
```
因此图片 live batch 在跨 MAIN bridge、写 IndexedDB 或上传 Bright 之前,已经可以交给发送确认 correlator。无需新增第二个 WebSocket observer,也不应增加另一套 raw payload parser。
## 6. 建议的图片确认模型
### 6.1 Pending 数据
现有 [`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 的 `PendingSend` 只保存字符串正文。图片支持应改为判别联合,而不是给文本结构追加一组可选字段:
```ts
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;
width: number;
height: number;
md5?: string | null;
fileId?: string;
};
};
type PendingSend = PendingTextSend | PendingImageSend;
```
`md5``fileId` 可以提高不同图片之间的区分度,但同一文件去重或重复发送时它们也可能相同,仍不能当作每次发送的唯一 ID。
### 6.2 登记时机
图片 pending 必须在以下时机登记:
```text
压缩完成
→ 上传/去重完成
→ buildFileRelationWithGroup
→ 得到最终 mediaInfo
→ 登记 PendingImageSend
→ 立即调用 sendUIMessages
```
更准确地说,应在 `buildFileRelationWithGroup` 成功、`sendFile` 已拿到最终 `nodeSize/width/height` 后,并在最终 SDK send 之前登记。
不能在用户选择原始文件时登记,原因有两个:
1. 图片压缩可能改变 `sizeBytes`,甚至改变尺寸;
2. 上传时间可能超过当前 correlator 的 `10_000ms` 超时。
上传阶段和消息发送确认阶段应是两个状态,不要让消息确认定时器覆盖完整上传耗时。
### 6.3 匹配顺序
建议匹配逻辑:
```text
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、width、height
7. 可选比较 md5/fileId,但不能把它们当作单次发送唯一键
8. 要求消息位于 pending 生命周期和允许的时钟偏差内
9. 一个消息必须只匹配一个 pending;多个匹配立即 send_ambiguous
```
概念判断:
```ts
const imageMatches = (
expected: PendingImageSend["expected"],
actual: OneTalkImageContent,
): boolean =>
actual.sizeBytes === expected.sizeBytes &&
actual.width === expected.width &&
actual.height === expected.height;
```
该函数只能是复合匹配的一部分,不能绕过 conversation、direction、时间窗口和唯一性检查。
## 7. 验证方法
### 7.1 自动化测试
至少需要补充以下测试:
| 用例 | 预期 |
| -------------------------- | ----------------------------------------------------------- |
| live WS 图片 raw payload | 输出 `content.kind=image` 及准确的 `sizeBytes/width/height` |
| 正确会话、方向、指纹和时间 | `confirmed_sent` |
| 错误 conversation | 不匹配 |
| `direction=received` | 不匹配 |
| 宽度、宽高或大小任一不同 | 不匹配 |
| 候选 message ID 匹配 | 即使时间窗口外仍按 ID 确认 |
| 候选 message ID 不匹配 | 不回退图片指纹 |
| 同图两个并发 pending | `send_ambiguous` |
| 图片压缩后大小变化 | 使用 post-upload 大小确认 |
| 超时无 live echo | `delivery_unknown/send_state_lost` |
| 非法 Base64/JSON/schema | anomaly,不进入 correlator |
现有测试已经覆盖 raw 图片解码、flat history 图片归一化、非法 live 媒体隔离和文本 send confirmation;尚缺成功 live 图片直接驱动 correlator 的用例。
### 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 完成判据
只有同时满足以下条件,才算图片发送确认链闭合:
```text
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 大小与宽高不唯一
同一图片重复发送会产生不同的 messageId,但 `sizeBytes/width/height` 完全相同。本次运行态已经得到两条这样的 live sent 记录。
不得采用:
```text
找到第一条同尺寸图片 → 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 图片解析。
## 9. 实现边界建议
该能力技术上可行,建议后续实现限定为:
1. 为页面图片发送定义独立、最小的输入合同;
2. 在上传关系成功后生成 post-upload image fingerprint
3.`PendingSend` 改成 text/image 判别联合;
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`](../apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts) 仍只接受字符串 `content`[`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts) 仍以文本正文为无 ID 回退条件。本报告确认的是图片扩展方案可行,不表示当前插件已经具备图片发送命令和图片发送确认合同。
## 10. 相关代码
- 页面 WebSocket tap[`websocket.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts)
- live frame 分发:[`index.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts)
- live message 解析:[`new.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts)
- MAIN 内容归一化:[`content-decoder.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts)
- 归一化媒体合同:[`content.ts`](../apps/onetalk-contract/src/content.ts)
- observer/correlator 接线:[`page-script-entry.ts`](../apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts)
- 当前文本发送确认:[`send-observation.ts`](../apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts)
- 当前页面命令入口:[`page-command.ts`](../apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts)
- 媒体解码测试:[`onetalk-media-content-decoder.test.js`](../apps/chrome-extension/test/onetalk-media-content-decoder.test.js)
- WebSocket observer 测试:[`onetalk-websocket-tap.test.js`](../apps/chrome-extension/test/onetalk-websocket-tap.test.js)