mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
docs(onetalk): document media message formats and sync plan
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
{"file": ".trellis/spec/project/architecture.md", "reason": "复核唯一类型 owner、依赖方向和第二事实源"}
|
||||
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "复核各边界 round-trip 和 history/live parity"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/quality-guidelines.md", "reason": "扩展测试、类型与构建门禁"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md", "reason": "复核 raw payload 不跨桥和账号精确路由"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md", "reason": "复核 IDB 清理、candidate、ACK 和 anchor"}
|
||||
{"file": ".trellis/spec/server/backend/database-guidelines.md", "reason": "复核 migration、JSONB check 与清理范围"}
|
||||
{"file": ".trellis/spec/server/backend/error-handling.md", "reason": "复核协议错误、OSS signer 失败和脱敏"}
|
||||
{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "Server 单元/集成/构建门禁"}
|
||||
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/current-cross-layer-evidence.md", "reason": "对照现状证据检查所有 raw 泄漏点已移除"}
|
||||
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/runtime-media-contract.md", "reason": "对照运行态样本检查媒体映射"}
|
||||
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/oss-presigned-download.md", "reason": "检查预签名 URL 期限、授权和日志边界"}
|
||||
@@ -0,0 +1,534 @@
|
||||
# OneTalk 图片与附件消息同步技术设计
|
||||
|
||||
## 1. 设计目标
|
||||
|
||||
在不改变消息身份、ACK、去重和同步锚点不变量的前提下,把 OneTalk 消息内容从“任意 JSON + 独立文本字段”收敛为版本化的 `text | image | file` 联合类型。
|
||||
|
||||
核心原则:
|
||||
|
||||
1. Raw OneTalk content 只在 MAIN world 出现。
|
||||
2. 共享 contract 是 normalized content 的唯一类型所有者。
|
||||
3. PostgreSQL `content JSONB` 是唯一持久化内容事实源。
|
||||
4. HTTP history 和 WS `message.created` 返回同一个数据库 message。
|
||||
5. 不支持的卡片和损坏媒体不伪装成其它类型,也不推进伪造 anchor。
|
||||
6. 媒体 URL 和升级 ZIP URL 都按 bearer-like 临时凭证处理。
|
||||
|
||||
## 2. 总体数据流
|
||||
|
||||
```text
|
||||
OneTalk history / live message
|
||||
→ MAIN envelope parser
|
||||
→ MAIN content decoder
|
||||
├─ text → normalized text
|
||||
├─ image → Base64 → UTF-8 → JSON → image schema
|
||||
├─ file → Base64 → UTF-8 → JSON → cardType=12 file schema
|
||||
├─ unsupported card → safe skipped counter
|
||||
└─ malformed media → safe anomaly
|
||||
→ page bridge v2(normalized message + safe batch diagnostics)
|
||||
→ Service Worker IndexedDB v5
|
||||
→ protocol v3 message.observed
|
||||
→ Server shared decoder
|
||||
→ PostgreSQL content JSONB
|
||||
→ DB commit
|
||||
→ message.ack
|
||||
→ accepted 时发布 message.created
|
||||
→ HTTP history / WS event
|
||||
→ /harness 同一 message renderer
|
||||
```
|
||||
|
||||
## 3. 版本边界
|
||||
|
||||
| 边界 | 新版本 | 含义 |
|
||||
| --- | --- | --- |
|
||||
| OneTalk WebSocket protocol | `3` | `OneTalkMessage.content` 只允许 normalized content |
|
||||
| Content schema | `1` | 每个 content 自带 `version: 1` |
|
||||
| MAIN ↔ ISOLATED ↔ SW page bridge | `2` | raw content 不得跨桥,增加安全 batch diagnostics 与升级 UI 控制消息 |
|
||||
| 扩展 IndexedDB | `5` | 清理 v2 raw message/candidate/checkpoint/anomaly,保留 profile |
|
||||
|
||||
协议版本与内容版本独立:传输 envelope 的不兼容变化提升 `protocolVersion`;仅 content union 的未来变化提升 `content.version`。
|
||||
|
||||
## 4. 共享内容合同
|
||||
|
||||
共享 owner 新建在 `apps/onetalk-contract/src/content.ts`,由包入口统一导出。Extension、Server 和 `/harness` 不得各自定义私有媒体 schema。
|
||||
|
||||
```ts
|
||||
export const ONETALK_CONTENT_VERSION = 1 as const;
|
||||
|
||||
export type OneTalkTextContent = {
|
||||
version: 1;
|
||||
kind: "text";
|
||||
text: string;
|
||||
};
|
||||
|
||||
export 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";
|
||||
};
|
||||
|
||||
export 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";
|
||||
};
|
||||
|
||||
export type OneTalkMessageContent =
|
||||
| OneTalkTextContent
|
||||
| OneTalkImageContent
|
||||
| OneTalkFileContent;
|
||||
```
|
||||
|
||||
### 4.1 Exact-shape 规则
|
||||
|
||||
- 每个分支只接受声明字段;多余字段拒绝。
|
||||
- `version` 必须严格为 `1`,`kind` 必须匹配分支。
|
||||
- ID、文件名和扩展名有长度上限;字符串 trim 后不得为空。
|
||||
- `sizeBytes`、尺寸必须为非负安全整数并设置领域上限。
|
||||
- `downloadState="available"` 当且仅当 `downloadUrl !== null`;`not_provided` 当且仅当 URL 为 `null`。
|
||||
- URL 为 `null` 表示 payload 未提供当前可用动作;空字符串不得进入 normalized contract。
|
||||
- `md5` 缺失或空值统一为 `null`。
|
||||
|
||||
### 4.2 OneTalkMessage v3
|
||||
|
||||
```ts
|
||||
export type OneTalkMessage = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
direction: OneTalkDirection;
|
||||
sentAtMs: number;
|
||||
content: OneTalkMessageContent;
|
||||
participantIds: string[];
|
||||
readStatus: number;
|
||||
messageStatus: number;
|
||||
unreadCount: number;
|
||||
};
|
||||
```
|
||||
|
||||
删除顶层 `text` 和 `contentType`。`OneTalkObservedMessage`、发送确认消息、history 和实时事件全部引用同一个类型。
|
||||
|
||||
## 5. MAIN-world raw content decoder
|
||||
|
||||
### 5.1 所有权
|
||||
|
||||
新增 `apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts`。它是唯一理解 OneTalk `contentType/custom.type/custom.data` 的模块。
|
||||
|
||||
`history.ts` 和 `new.ts` 继续只负责定位各自 envelope 中的消息数组,然后调用同一个 message parser;不得复制媒体分支。
|
||||
|
||||
### 5.2 返回类型
|
||||
|
||||
```ts
|
||||
type OneTalkRawContentDecodeResult =
|
||||
| { status: "decoded"; content: OneTalkMessageContent }
|
||||
| {
|
||||
status: "unsupported_skipped";
|
||||
sourceContentType: number;
|
||||
sourceCustomType: number | null;
|
||||
cardType: number | null;
|
||||
}
|
||||
| {
|
||||
status: "anomaly";
|
||||
code:
|
||||
| "media_invalid_base64"
|
||||
| "media_invalid_utf8"
|
||||
| "media_invalid_json"
|
||||
| "media_payload_too_large"
|
||||
| "media_invalid_schema"
|
||||
| "media_invalid_url";
|
||||
mediaKind: "image" | "file";
|
||||
};
|
||||
```
|
||||
|
||||
Raw content、消息 ID、正文和 URL 不进入 skip/anomaly 输出。
|
||||
|
||||
### 5.3 文本
|
||||
|
||||
判定 `contentType=1` 且存在 `text.content: string`:
|
||||
|
||||
```ts
|
||||
{
|
||||
version: 1,
|
||||
kind: "text",
|
||||
text: content.text.content,
|
||||
}
|
||||
```
|
||||
|
||||
丢弃 `text.extension` 和其它 raw 字段,包括序列化的 `basicMessageInfo/chatToken`。
|
||||
|
||||
### 5.4 图片
|
||||
|
||||
判定:
|
||||
|
||||
```text
|
||||
contentType=101
|
||||
custom.type=7
|
||||
```
|
||||
|
||||
流程:
|
||||
|
||||
```text
|
||||
custom.data length gate
|
||||
→ strict Base64
|
||||
→ fatal UTF-8 decode
|
||||
→ JSON object
|
||||
→ exact image payload schema
|
||||
→ URL policy
|
||||
→ OneTalkImageContent
|
||||
```
|
||||
|
||||
字段映射:
|
||||
|
||||
```text
|
||||
fileId ← decoded.fileId
|
||||
extension ← lowercase(decoded.suffix)
|
||||
sizeBytes ← decoded.size
|
||||
width ← decoded.width
|
||||
height ← decoded.height
|
||||
isOriginal ← decoded.isOriginal === 1
|
||||
md5 ← decoded.md5 || null
|
||||
previewUrl ← validated(decoded.url) or null
|
||||
urlScope ← "onetalk_session"
|
||||
```
|
||||
|
||||
### 5.5 文件
|
||||
|
||||
判定:
|
||||
|
||||
```text
|
||||
contentType=101
|
||||
custom.type=10010
|
||||
decoded.cardType=12
|
||||
```
|
||||
|
||||
`custom.type=10010` 但 `cardType!==12` 返回 `unsupported_skipped`。
|
||||
|
||||
字段映射:
|
||||
|
||||
```text
|
||||
fileId ← params.id
|
||||
parentId ← params.parentId
|
||||
fileName ← params.name
|
||||
extension ← lowercase(params.extensionType)
|
||||
sizeBytes ← decimal string params.size → safe integer
|
||||
md5 ← params.md5 || null
|
||||
thumbnailUrl ← validated non-empty params.thumbnailUrl or null
|
||||
```
|
||||
|
||||
文件名后缀必须与 `extensionType` 一致。通用文件合同不猜 MIME;ZIP/PDF 是本任务仅有的运行态已验证样本。
|
||||
|
||||
### 5.6 文件访问动作
|
||||
|
||||
URL 不允许修改 query。选择规则:
|
||||
|
||||
```ts
|
||||
const explicitDownload = validateOptionalFileDownloadUrl(params.downloadUrl);
|
||||
const source = validateOptionalFileActionUrl(params.url);
|
||||
|
||||
const downloadUrl =
|
||||
explicitDownload ?? (source?.action === "download" ? source.url : null);
|
||||
const previewUrl = source?.action === "officePreview" ? source.url : null;
|
||||
```
|
||||
|
||||
- `downloadUrl` 非空且通过下载 URL policy 时优先。
|
||||
- `params.url.fileAction=download` 时作为下载地址。
|
||||
- `params.url.fileAction=officePreview` 时只作为预览地址。
|
||||
- `thumbnailUrl` 永不作为下载地址。
|
||||
- PDF 当前得到 `previewUrl=params.url`、`downloadUrl=null`。
|
||||
- ZIP 当前得到 `previewUrl=null`、`downloadUrl=params.url`。
|
||||
|
||||
### 5.7 媒体 URL policy
|
||||
|
||||
首层校验在 MAIN decoder,Server shared decoder 再做同样的结构约束:
|
||||
|
||||
- 必须是绝对 HTTPS URL。
|
||||
- 禁止 username/password、fragment 和超长 URL。
|
||||
- 当前 allowlist host:`clouddisk.alibaba.com`。
|
||||
- 图片/文件 action URL 路径:`/file/redirectFileUrl.htm`。
|
||||
- 图片允许 `fileAction=imagePreview`。
|
||||
- 文件允许 `fileAction=download|officePreview`。
|
||||
- thumbnail 路径:`/file/videoThumb.htm`。
|
||||
- query key 只允许运行态确认的 `appkey,fileAction,id,parentId,scene,secOperateAliId`;values 作为不透明值保留,绝不进入诊断。
|
||||
|
||||
空 URL转换为 `null`;非空但违反 policy 的 URL 是 anomaly,不能静默删除后继续。
|
||||
|
||||
## 6. Batch、skip、anomaly 与 anchor
|
||||
|
||||
MAIN parser 返回:
|
||||
|
||||
```ts
|
||||
type OneTalkParsedBatch = {
|
||||
messages: OneTalkObservedMessage[];
|
||||
diagnostics: {
|
||||
unsupportedSkippedCount: number;
|
||||
anomalies: Array<{
|
||||
code: OneTalkMediaAnomalyCode;
|
||||
mediaKind: "image" | "file";
|
||||
count: number;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
- `unsupported_skipped` 和相同 anomaly 在 batch 内聚合,只把类型、错误码和计数跨桥。
|
||||
- Skip/anomaly 不创建 IndexedDB message/candidate,不上传 Server。
|
||||
- 存在 anomaly 时,本批结果为 `succeeded_with_anomalies`;只有 unsupported skip 时同步仍成功,但状态/诊断带 skip 计数。
|
||||
- Anchor 只取已获得 `accepted|duplicate` ACK 的最新有效消息。
|
||||
- 若最新原始消息被跳过,anchor 保持在最近有效消息;后续增量可能再次扫描该项,诊断去重但不得合成 anchor。
|
||||
|
||||
## 7. Page bridge v2 与扩展 IndexedDB v5
|
||||
|
||||
### 7.1 Page bridge
|
||||
|
||||
- `ONE_TALK_PAGE_BRIDGE_VERSION: 1 → 2`。
|
||||
- message observation 中只允许 normalized `OneTalkObservedMessage`。
|
||||
- 增加 `diagnostics` 安全聚合字段;exact guard 拒绝 raw `custom`、`custom.data`、`text.extension` 或未知字段。
|
||||
- ISOLATED 仍只验证 source/origin/方向并转发业务消息,不解释媒体。
|
||||
|
||||
### 7.2 IndexedDB migration
|
||||
|
||||
- `ONE_TALK_SYNC_DATABASE_VERSION: 4 → 5`。
|
||||
- `oldVersion < 5` 时,在 versionchange transaction 中清空:
|
||||
- `onetalk_messages`
|
||||
- `onetalk_sync_candidates`
|
||||
- `onetalk_sync_checkpoints`
|
||||
- `onetalk_sync_anomalies`
|
||||
- 保留 `onetalk_contact_profiles`。
|
||||
- 扩展配置和 deviceId 位于 `chrome.storage.local`,不参与清理。
|
||||
- 空 checkpoint 使 v3 首次运行自然进入 full sync;不得额外维护迁移完成的第二状态。
|
||||
|
||||
## 8. Server normalized boundary 与 PostgreSQL
|
||||
|
||||
### 8.1 Service
|
||||
|
||||
- `message.observed` 必须先经过共享 exact decoder。
|
||||
- Server 不再尝试理解或清洗 OneTalk raw media;收到 raw `custom.data` 直接判 `invalid_message/anomaly`,不入库。
|
||||
- `content` 通过后,Server 只组合授权上下文和数据库操作。
|
||||
- 既有 commit guard、授权 fence、ACK 和 publish 顺序保持不变。
|
||||
|
||||
### 8.2 Repository
|
||||
|
||||
- Repository row 的 `content` 类型改为 `OneTalkMessageContent`。
|
||||
- `toMessage` 原样返回 DB content,不创建第二次媒体投影。
|
||||
- 复合幂等键、时间索引、sender 索引和 duplicate 行为保持不变。
|
||||
|
||||
### 8.3 新 PostgreSQL migration
|
||||
|
||||
不得编辑已应用迁移,新增下一号 migration:
|
||||
|
||||
1. `DELETE FROM onetalk_message`。
|
||||
2. 清理旧 `onetalk_message_anomaly` 开发诊断。
|
||||
3. 重置 `onetalk_conversation`:
|
||||
- `sync_phase='initial'`
|
||||
- `sync_result='incomplete'`
|
||||
- `latest_message_id=NULL`
|
||||
- `history_complete=false`
|
||||
- `message_count=0`
|
||||
- `anchor_updated_at=NULL`
|
||||
4. 从 `onetalk_message` 删除 `text`、`content_type` 列。
|
||||
5. 为 `content` 增加轻量 DB CHECK:JSON object、`version=1`、`kind IN ('text','image','file')`。完整 exact-shape 仍由共享 decoder 负责。
|
||||
|
||||
不得删除 conversation identity、授权/binding、联系人 profile 或其它渠道数据。迁移只在部署时执行,本任务测试不得对用户数据库直接运行破坏性命令。
|
||||
|
||||
## 9. Mind-facing history/event
|
||||
|
||||
### 9.1 HTTP history
|
||||
|
||||
外层响应保持:
|
||||
|
||||
```ts
|
||||
{
|
||||
scope: OneTalkMindScope;
|
||||
conversationId: string;
|
||||
messages: OneTalkMessage[];
|
||||
page: { hasMore: boolean; nextCursor: string | null };
|
||||
}
|
||||
```
|
||||
|
||||
### 9.2 实时事件
|
||||
|
||||
```ts
|
||||
{
|
||||
protocolVersion: 3;
|
||||
connectionType: "mind_page";
|
||||
type: "message.created";
|
||||
requestId: string;
|
||||
scope: OneTalkMindScope;
|
||||
payload: { message: OneTalkMessage };
|
||||
}
|
||||
```
|
||||
|
||||
同一消息的 `history.messages[i]` 必须与 `message.created.payload.message` 深度等价。两者外层 envelope 不要求相同。
|
||||
|
||||
## 10. `/harness` 调试展示
|
||||
|
||||
`/harness` 继续让 history/live 进入同一个 message Map 和 renderer:
|
||||
|
||||
- `text`:安全文本节点。
|
||||
- `image`:展示元数据并用真实 `<img src=previewUrl>` 加载;null/失败显示明确状态。
|
||||
- `file`:展示文件名、扩展名和大小;仅在 URL 非空时显示预览/下载按钮。
|
||||
- 下载只由用户点击触发,不自动下载。
|
||||
- 外链使用 `target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer"`。
|
||||
- 保留经过 HTML 转义的 normalized JSON 诊断区。
|
||||
- 不解析 `custom.data`,不增加媒体发送 UI。
|
||||
|
||||
## 11. 协议升级提示与 OSS ZIP
|
||||
|
||||
### 11.1 Upgrade notice
|
||||
|
||||
普通 `decodeOneTalkFrame` 仍 fail closed。另提供一个只能识别升级通知的版本无关 decoder,并在普通版本检查之前调用:
|
||||
|
||||
```ts
|
||||
type OneTalkProtocolUpgradeNotice = {
|
||||
protocolVersion: number;
|
||||
connectionType: "plugin";
|
||||
type: "ws.error";
|
||||
requestId: string;
|
||||
scope: OneTalkPluginScope;
|
||||
payload: {
|
||||
code: "onetalk_protocol_upgrade_required";
|
||||
targetProtocolVersion: number;
|
||||
targetExtensionVersion: string;
|
||||
downloadUrl: string;
|
||||
expiresAtMs: number;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
该 decoder 只接受上述 exact shape,不让其它错误或业务帧绕过版本校验。它验证 HTTPS、无 userinfo/fragment、ZIP 路径、有效期未过且不超过允许的 24 小时窗口。OSS endpoint 的精确 host 校验由持有运行时配置的 Server signer adapter 负责;插件信任已授权 Bright 连接,不维护第二份 endpoint 配置。
|
||||
|
||||
### 11.2 Server mismatch 流程
|
||||
|
||||
```text
|
||||
exact WebSocket Origin
|
||||
→ strict legacy hello pre-parser
|
||||
→ scope + binding authorization
|
||||
→ capture current policy/auth fence
|
||||
→ OSS signer generates 24h GET URL
|
||||
→ fence recheck
|
||||
→ send upgrade notice
|
||||
→ close 1003 / protocol_upgrade_required
|
||||
```
|
||||
|
||||
未经授权、非法 hello、scope mismatch 或 signer 失败时不得发送签名 URL。诊断只能包含错误码、目标版本、是否已签名和 expiry 状态。
|
||||
|
||||
### 11.3 Signer
|
||||
|
||||
Server 定义窄接口:
|
||||
|
||||
```ts
|
||||
type OneTalkExtensionDownloadSigner = {
|
||||
signDownload(): Promise<{
|
||||
targetExtensionVersion: string;
|
||||
downloadUrl: string;
|
||||
expiresAtMs: number;
|
||||
}>;
|
||||
};
|
||||
```
|
||||
|
||||
生产 adapter 使用阿里云官方 `ali-oss` Node SDK 的 V4 GET 预签名能力,固定 `expires=86400` 秒。配置:
|
||||
|
||||
```text
|
||||
ONETALK_EXTENSION_OSS_REGION
|
||||
ONETALK_EXTENSION_OSS_ENDPOINT
|
||||
ONETALK_EXTENSION_OSS_BUCKET
|
||||
ONETALK_EXTENSION_OSS_OBJECT_KEY
|
||||
ONETALK_EXTENSION_TARGET_VERSION
|
||||
ONETALK_EXTENSION_OSS_ACCESS_KEY_ID
|
||||
ONETALK_EXTENSION_OSS_ACCESS_KEY_SECRET
|
||||
ONETALK_EXTENSION_OSS_STS_TOKEN # 可选
|
||||
```
|
||||
|
||||
生产缺失/非法配置时 `loadConfig` 失败。`dev-entry.ts` 和测试显式注入 fake signer,不连接 OSS。
|
||||
|
||||
私有 OSS object 是不可变、版本化 ZIP,内容为扩展 `dist/`;目标版本来自配置并使用现有 package-version validator 规则校验。
|
||||
|
||||
### 11.4 插件 UI 路由
|
||||
|
||||
1. Bright client 先尝试 `decodeOneTalkProtocolUpgradeNotice`。
|
||||
2. 合法 notice 通过专用 callback 交给 configured session/runtime。
|
||||
3. Service Worker 仅向同一 `channelAccountId` 的已注册 OneTalk tab 发送 isolated control message,不按 URL 或当前 tab 回退。
|
||||
4. ISOLATED content script 拦截 control message,不转发到 MAIN,使用 closed Shadow DOM 渲染顶部 banner。
|
||||
5. 完整签名 URL 只存在于 SW 内存和目标 tab DOM;不写 `chrome.storage`、IndexedDB 或 console。
|
||||
|
||||
Banner:
|
||||
|
||||
- 固定页面顶部,`role="alert"`,非模态,不阻断 OneTalk。
|
||||
- 文案:“插件版本过低,消息同步已停止”。
|
||||
- 显示目标版本和 ZIP 解压/加载说明。
|
||||
- 仅合法且未过期时显示“下载新版本”按钮。
|
||||
- 过期后禁用按钮并提示重新加载页面获取新链接。
|
||||
- 协议阻断期间不可关闭;收到兼容 `ws.accepted` 后自动移除。
|
||||
|
||||
## 12. 日志与安全
|
||||
|
||||
- 删除 raw OneTalk WebSocket frame/完整 parsed message console 输出。
|
||||
- 扩展和 Server diagnostics 只允许稳定事件名、错误码、方向、frame type、计数和布尔状态。
|
||||
- 禁止记录 raw `custom.data`、正文、完整媒体 URL、OSS URL、query values、binding、Cookie、token、文件 ID或消息 ID。
|
||||
- 测试增加字符串与 Base64 嵌套敏感键的负向 fixture,证明 MAIN 出口后不存在这些字段。
|
||||
|
||||
## 13. 失败矩阵
|
||||
|
||||
| 条件 | 结果 |
|
||||
| --- | --- |
|
||||
| 非文件 `cardType` | 聚合 `unsupported_skipped`;不跨桥、不入库;同步继续 |
|
||||
| 非法 Base64/UTF-8/JSON | 脱敏 anomaly;无 candidate;批次 `succeeded_with_anomalies` |
|
||||
| 媒体 schema/URL 非法 | 脱敏 anomaly;无 candidate;批次继续 |
|
||||
| Server 收到 raw/未知 content version | anomaly/invalid;不入库、不 ACK accepted、不 publish |
|
||||
| duplicate v3 message | 返回原 DB 事实,ACK duplicate,不重复 publish |
|
||||
| PDF 无下载 URL | file 入库;`downloadUrl=null`、`downloadState=not_provided` |
|
||||
| 图片 previewUrl 为空 | image 元数据仍可入库;harness 显示不可预览状态 |
|
||||
| Upgrade hello 未授权 | 不生成或发送 OSS URL |
|
||||
| OSS signer 失败 | 不发送 URL;关闭连接并安全诊断 |
|
||||
| Upgrade URL 过期 | banner 保持,下载按钮禁用,提示 reload |
|
||||
| live 媒体无真实样本 | 自动测试覆盖共享 decoder;运行态验收标记待补证 |
|
||||
|
||||
## 14. 备选方案与取舍
|
||||
|
||||
- v2 原位改语义:拒绝;同一版本会同时表示 raw/normalized。
|
||||
- v2/v3 双栈:拒绝;当前版本未发布,没有承担长期 raw 兼容的价值。
|
||||
- 媒体独立表:拒绝;第一阶段无媒体索引或二进制生命周期需求。
|
||||
- Server 媒体代理:拒绝;超出本任务且增加 Cookie/授权/带宽边界。
|
||||
- 保存 unsupported:拒绝;用户决定暂不处理,安全计数后跳过。
|
||||
- raw 数据迁移:拒绝;开发数据精确清理后 full sync。
|
||||
- 构建时固定下载 URL/稳定下载入口:拒绝;Server 直接发送 24h OSS 签名 URL。
|
||||
- 阻塞式升级弹窗:拒绝;顶部非模态 banner 不影响 OneTalk 使用。
|
||||
|
||||
## 15. 验证策略
|
||||
|
||||
1. 共享 contract:三分支 exact decoder、content version、升级 notice decoder。
|
||||
2. MAIN decoder:文本/JPEG/ZIP/PDF、非文件卡片和所有异常分支。
|
||||
3. History/live:相同 fixture 输出深度等价。
|
||||
4. Page bridge:raw 和未知字段拒绝,安全 diagnostics 通过。
|
||||
5. IndexedDB v5:清理四个同步 store,保留 profile,随后 full sync。
|
||||
6. Server:三分支入库/读取、duplicate、commit→ACK→publish 顺序。
|
||||
7. PostgreSQL:新 migration、CHECK、旧列删除、精确数据重置。
|
||||
8. Mind parity:history message 与 `message.created` 深度等价。
|
||||
9. OSS:生产配置 fail fast;fake signer;授权前不得签发。
|
||||
10. Banner:合法/非法/缺失/过期 URL、认证恢复、非阻塞 DOM 行为。
|
||||
11. `/harness`:真实图片加载、文件按钮状态、history/live 去重。
|
||||
12. CDP smoke:真实历史 JPEG/ZIP/PDF;真实 live 媒体待样本。
|
||||
|
||||
## 16. 回滚
|
||||
|
||||
- 应用代码回滚必须与数据库 migration 协调;删除 `text/content_type` 后不能单独部署 v2 Server。
|
||||
- 在正式部署前保留数据库备份;回滚 v2 需要恢复备份而不是从 normalized JSON 猜造旧 raw content。
|
||||
- 扩展 IndexedDB v5 清理不可逆,但仅清理可重新同步的消息状态;配置、deviceId 和 profile 可继续使用。
|
||||
- OSS 签名功能可以通过回滚 Server 停止签发;已签 URL在 24 小时内仍可能有效,这是已知撤销窗口。
|
||||
@@ -0,0 +1,11 @@
|
||||
{"_example": "Fill with {\"file\": \"<path>\", \"reason\": \"<why>\"}. Put spec/research files only — no code paths. Run `python3 .trellis/scripts/get_context.py --mode packages` to list available specs. Delete this line once real entries are added."}
|
||||
{"file": ".trellis/spec/project/architecture.md", "reason": "跨包类型所有权、模块职责和边界"}
|
||||
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "插件到数据库再到 Mind 的跨层合同检查"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md", "reason": "OneTalk 双向运行时、身份、ACK 与同步不变量"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md", "reason": "MAIN/ISOLATED/SW 页面桥和精确路由"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md", "reason": "IndexedDB、candidate、checkpoint 与恢复"}
|
||||
{"file": ".trellis/spec/server/backend/database-guidelines.md", "reason": "Drizzle migration、JSONB 与持久化边界"}
|
||||
{"file": ".trellis/spec/server/backend/service-foundation.md", "reason": "WebSocket 授权、提交、ACK 和 publish 顺序"}
|
||||
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/current-cross-layer-evidence.md", "reason": "当前 raw content 跨层链路与受影响文件"}
|
||||
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/runtime-media-contract.md", "reason": "JPEG、ZIP、PDF 的真实运行态字段与 URL action"}
|
||||
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/oss-presigned-download.md", "reason": "私有 OSS V4 预签名与安全边界"}
|
||||
@@ -0,0 +1,227 @@
|
||||
# OneTalk 图片与附件消息同步实施计划
|
||||
|
||||
## 1. 前置约束
|
||||
|
||||
- 当前 task 保持 `planning`,只有用户审核最终计划后才能执行 `task.py start`。
|
||||
- 不修改独立 `trade-mind`。
|
||||
- 不进行真实媒体发送、二进制下载或上传。
|
||||
- 不在用户数据库执行手工删除;数据清理由新增 migration 表达并在隔离测试数据库验证。
|
||||
- 现有未提交 docs 属于并行工作,实施提交只暂存本 task 拥有的文件。
|
||||
|
||||
## 2. 实施顺序
|
||||
|
||||
### Phase 1:共享 contract v3/content v1
|
||||
|
||||
1. 新建 `apps/onetalk-contract/src/content.ts`:
|
||||
- `ONETALK_CONTENT_VERSION=1`
|
||||
- `OneTalkTextContent`
|
||||
- `OneTalkImageContent`
|
||||
- `OneTalkFileContent`
|
||||
- `OneTalkMessageContent`
|
||||
- exact-shape content decoder/guard
|
||||
- 媒体 URL、扩展名、数值和 downloadState 一致性校验
|
||||
2. 更新 `apps/onetalk-contract/src/model.ts`:
|
||||
- `ONETALK_PROTOCOL_VERSION=3`
|
||||
- `OneTalkMessage/OneTalkObservedMessage` 使用 `OneTalkMessageContent`
|
||||
- 删除顶层 `text/contentType`
|
||||
- 定义带 target protocol/version、download URL、expiry 的 upgrade notice 类型
|
||||
3. 更新 `apps/onetalk-contract/src/decoder.ts`:
|
||||
- 普通帧严格要求 v3
|
||||
- 增加版本无关但 exact-shape 的 `decodeOneTalkProtocolUpgradeNotice`
|
||||
- raw/未知 content version fail closed
|
||||
4. 更新包出口和 contract tests。
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
pnpm --filter @trade-message-center/onetalk-contract test
|
||||
pnpm --filter @trade-message-center/onetalk-contract typecheck
|
||||
```
|
||||
|
||||
### Phase 2:MAIN-world 媒体解析
|
||||
|
||||
1. 新建 `message-observer/content-decoder.ts`:
|
||||
- text/image/file 判别
|
||||
- Base64 → fatal UTF-8 → JSON
|
||||
- payload 大小和 exact schema 校验
|
||||
- OneTalk media URL policy
|
||||
- ZIP/PDF action 映射
|
||||
- unsupported skip 与 anomaly 结果
|
||||
2. 修改 `message-observer/model.ts`,只输出 normalized message 与 safe batch diagnostics。
|
||||
3. 让 `history.ts`、`new.ts` 和 live MessagePack 入口复用同一 decoder。
|
||||
4. 删除 `message-observer/websocket.ts` 的 raw frame/parsed message console 输出,替换为字段白名单诊断。
|
||||
5. 调整文本发送确认相关 mapper,使现有文本发送事实生成 `{version:1,kind:"text"}`;不增加媒体发送。
|
||||
|
||||
测试:
|
||||
|
||||
- 文本/JPEG/ZIP/PDF sanitized fixtures。
|
||||
- `cardType=2000` skip。
|
||||
- invalid Base64、fatal UTF-8、JSON、超限、schema、URL。
|
||||
- 字符串/Base64 嵌套 `chatToken` 不跨 decoder。
|
||||
- history/live 同 fixture 深度等价。
|
||||
|
||||
### Phase 3:Page bridge v2 与 IndexedDB v5
|
||||
|
||||
1. `ONE_TALK_PAGE_BRIDGE_VERSION: 1 → 2`。
|
||||
2. 收紧 page message guard:只允许 normalized content 与聚合 diagnostics。
|
||||
3. 更新 Service Worker observation pipeline、candidate、ACK helper 的类型。
|
||||
4. `ONE_TALK_SYNC_DATABASE_VERSION: 4 → 5`。
|
||||
5. v5 upgrade transaction 清空 message/candidate/checkpoint/anomaly stores,保留 profile store。
|
||||
6. 验证配置和 deviceId 所在 `chrome.storage.local` 不受影响。
|
||||
7. 验证无 checkpoint 后首次启动进入 full sync,不恢复旧 pending raw。
|
||||
|
||||
重点测试:
|
||||
|
||||
- raw `custom.data/text.extension` 无法穿过 page bridge。
|
||||
- IDB v4→v5 清理精确且原子。
|
||||
- profile ledger 保留。
|
||||
- candidate durability、ACK、断线恢复、anchor/completion 既有不变量不变。
|
||||
|
||||
### Phase 4:Server normalized 存储与 PostgreSQL migration
|
||||
|
||||
1. 修改 Server service:
|
||||
- 只接受共享 normalized content decoder 产物
|
||||
- 删除任意 raw JSON 的业务清洗/放行路径
|
||||
- 保留 commit guard 和 anomaly 边界
|
||||
2. 修改 repository/schema:
|
||||
- row content 类型为 `OneTalkMessageContent`
|
||||
- 删除 `text/contentType` 映射
|
||||
- history/event 直接返回 DB message
|
||||
3. 生成新 Drizzle migration:
|
||||
- 删除旧 message/anomaly 开发数据
|
||||
- 重置 conversation message_count/anchor/sync state
|
||||
- drop `text/content_type`
|
||||
- add content version/kind CHECK
|
||||
4. 更新 migration metadata,不编辑 `0000..0002`。
|
||||
5. 增加真实 PostgreSQL migration/round-trip/duplicate/pagination 测试。
|
||||
|
||||
验证:
|
||||
|
||||
```bash
|
||||
pnpm --filter @trade-message-center/server db:check
|
||||
pnpm --filter @trade-message-center/server test
|
||||
pnpm --filter @trade-message-center/server test:integration
|
||||
```
|
||||
|
||||
若 `TEST_DATABASE_URL` 不存在,integration 明确报告 skipped,不宣称 PostgreSQL 已验证。
|
||||
|
||||
### Phase 5:协议升级 OSS notice
|
||||
|
||||
1. 增加 Server `OneTalkExtensionDownloadSigner` 接口与阿里云 OSS adapter。
|
||||
2. 为 `apps/server` 增加官方 `ali-oss` 依赖,使用 V4 GET 预签名和 `86400` 秒有效期。
|
||||
3. 扩展 Server config:region、endpoint、bucket、versioned object key、target extension version、credentials/optional STS token。
|
||||
4. production 配置缺失/非法时 fail fast;dev/test 显式注入 fake signer。
|
||||
5. 在 WebSocket protocol mismatch 路径中:
|
||||
- strict legacy hello pre-parse
|
||||
- Origin/scope/binding authorize
|
||||
- signer
|
||||
- post-await fence
|
||||
- 发送 upgrade notice
|
||||
- close 1003
|
||||
6. 未授权、无效 hello、signer failure 不返回 OSS URL。
|
||||
7. Server/extension diagnostics 不记录 URL 或签名 query。
|
||||
|
||||
重点测试:
|
||||
|
||||
- notice 能绕过普通版本拒绝但只接受 exact upgrade shape。
|
||||
- auth 前 signer 未调用。
|
||||
- authorization revoke/pause/replacement 后不发送迟到 URL。
|
||||
- 24h expiresAtMs 与 fake clock。
|
||||
- config secret 不进入 error/log。
|
||||
|
||||
### Phase 6:OneTalk 页面升级横幅
|
||||
|
||||
1. Bright client 在普通 frame decoder 前识别 upgrade notice。
|
||||
2. configured session/runtime 保存内存态 upgrade notice,并按精确 `channelAccountId` 路由到已注册 tab。
|
||||
3. 定义 isolated-only control message;不得转发到 MAIN。
|
||||
4. 新增 closed Shadow DOM banner:
|
||||
- fixed top、role alert、非模态
|
||||
- 不可关闭
|
||||
- target version、ZIP 解压/加载说明
|
||||
- 合法且未过期才显示下载按钮
|
||||
- expiry 后禁用并提示 reload
|
||||
- compatible `ws.accepted` 后清除
|
||||
5. 链接使用 noopener/noreferrer/no-referrer,不持久化签名 URL。
|
||||
|
||||
重点测试:
|
||||
|
||||
- 多 tab 按账号精确路由,不广播到其它账号。
|
||||
- 缺失/非法/过期 URL 不显示可用按钮。
|
||||
- banner 不修改 OneTalk 业务 DOM 结构或屏蔽交互。
|
||||
- SW restart 后 URL 不从 storage 恢复,重新连接重新获取。
|
||||
|
||||
### Phase 7:Mind-facing history/event 与 `/harness`
|
||||
|
||||
1. 更新 HTTP/WS fixtures 和 guards,只接受 v3/content v1。
|
||||
2. 增加同一 DB message 在 history 与 `message.created` 中深度等价的跨层测试。
|
||||
3. 更新 `/harness` renderer:
|
||||
- text 文本节点
|
||||
- image 真 `<img>` + load/error 状态
|
||||
- file 元数据 + 条件预览/下载按钮
|
||||
- normalized JSON 调试区
|
||||
4. 保持 history/live 同 Map 去重;不增加媒体发送 UI。
|
||||
5. 外链安全属性与失败状态测试。
|
||||
|
||||
### Phase 8:文档、规范和版本一致性
|
||||
|
||||
1. 更新 OneTalk runtime/page-bridge/durable-sync/server DB/Mind history 规范。
|
||||
2. 将最终媒体合同同步回相关 docs,清除旧 `cardType=0`、raw 可跨层等过时结论。
|
||||
3. 更新 `.env.example`,只写变量名和安全占位,不写真实 OSS 信息。
|
||||
4. 校验根版本、子 package 版本和生成 manifest 的一致性;目标扩展版本配置必须符合现有版本校验规则。
|
||||
|
||||
## 3. 全量验证
|
||||
|
||||
按顺序执行:
|
||||
|
||||
```bash
|
||||
pnpm --filter @trade-message-center/onetalk-contract test
|
||||
pnpm --filter @trade-message-center/chrome-extension test
|
||||
pnpm --filter @trade-message-center/server test
|
||||
pnpm --filter @trade-message-center/server db:check
|
||||
pnpm --filter @trade-message-center/server test:integration
|
||||
pnpm format:check
|
||||
pnpm typecheck
|
||||
pnpm test
|
||||
pnpm build
|
||||
git diff --check
|
||||
```
|
||||
|
||||
运行态 smoke:
|
||||
|
||||
1. 用 `--remote-debugging-address=127.0.0.1 --remote-debugging-port=9222` 连接真实 Chromium。
|
||||
2. 真实历史 JPEG/ZIP/PDF:验证 MAIN normalized 输出、page bridge、IDB、Server DB、history 和 `/harness`。
|
||||
3. `/harness` 真实 `<img>` 加载和文件按钮状态;不自动下载二进制。
|
||||
4. fake/controlled Server 发送 upgrade notice,验证 OneTalk banner、过期和恢复。
|
||||
5. 若没有真实 live 媒体样本,标记 `blocked_by_sample`,只报告 fixture 已通过。
|
||||
|
||||
## 4. 实施拆分与所有权
|
||||
|
||||
共享 contract 必须先完成,之后可并行:
|
||||
|
||||
- Workstream A:`apps/onetalk-contract`,内容/upgrade notice 的唯一类型 owner。
|
||||
- Workstream B:Chrome MAIN decoder、page bridge、IDB、upgrade banner。
|
||||
- Workstream C:Server service/repository/migration/OSS signer。
|
||||
- Workstream D:Mind-facing HTTP/WS parity 与 `/harness`。
|
||||
|
||||
并行实现者不得修改其它 workstream 的 owner;公共类型变化由 A 先稳定,再由 B/C/D 消费。最终由主代理做跨层 diff review 和集成验证。
|
||||
|
||||
## 5. 风险与回滚点
|
||||
|
||||
| 风险 | 控制 | 回滚点 |
|
||||
| --- | --- | --- |
|
||||
| v3 contract 影响所有 frame fixture | 先完成共享包与消费者编译 | Phase 1 commit |
|
||||
| IDB 清理范围过宽 | store allowlist + profile preservation test | Phase 3 commit |
|
||||
| PG migration 误删非消息事实 | SQL 精确表/列 + 隔离 DB test | migration 前备份 |
|
||||
| raw/sensitive content 残留 | bridge/DB/history/log grep + negative fixture | MAIN decoder boundary |
|
||||
| signed URL 未授权泄漏 | authorize before signer + late fence | signer module rollback |
|
||||
| history/live 格式漂移 | deepEqual parity test | shared repository mapper |
|
||||
| anchor 指向 skipped message | 仅 ACKed candidate 可推进 | ACK coordinator tests |
|
||||
| live 真实格式不同 | fixture coverage + runtime blocked_by_sample | 保留 feature 未宣称验证 |
|
||||
|
||||
## 6. 完成条件
|
||||
|
||||
- PRD 所有 acceptance criteria 有自动测试或明确的运行态证据。
|
||||
- 全量质量门禁通过。
|
||||
- PostgreSQL integration 和真实 Chromium smoke 的执行/跳过/阻塞状态明确列出。
|
||||
- diff review 未发现 raw payload、第二内容事实源、隐藏 fallback、跨账号路由或秘密日志回归。
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
# 支持 OneTalk 图片与附件消息同步
|
||||
|
||||
## Goal
|
||||
|
||||
将现有仅具备文本语义的 OneTalk 消息同步扩展为 `text | image | file` 内容合同,使插件能够安全解析并上传图片/附件消息,Server 能够持久化和分发统一格式,Mind 能够通过 history/event 接口获得可判别、可展示的媒体消息。
|
||||
|
||||
## Background
|
||||
|
||||
- 当前系统不是 wire 层拒绝媒体,而是 `content` 仅被建模为任意 JSON,消费友好语义只有独立 `text` 字段;图片/附件 raw content 能穿透,但没有稳定、安全、可渲染的 typed contract。
|
||||
- 当前已确认 OneTalk 文本、图片和附件共用历史消息链路。
|
||||
- 图片原始判定为 `contentType=101 + custom.type=7`,SDK 归一化为 `msgType=102 + subType=60`。
|
||||
- 文件原始判定为 `contentType=101 + custom.type=10010 + cardType=12`,SDK 归一化为 `msgType=10010 + subType=61 + cardType=12`。
|
||||
- `custom.type=10010` 还包含 `cardType=2000` 等非文件业务卡片,不能单独作为文件判定。
|
||||
- 当前真实图片样本为 JPEG;真实附件样本包含 ZIP 和 PDF。
|
||||
- ZIP 样本的 `params.url.fileAction=download`;PDF 样本的 `params.url.fileAction=officePreview`,两者的 `params.downloadUrl` 都为空。
|
||||
- 媒体 URL 是带身份相关 query 的 OneTalk 操作地址,跨 Origin、长期有效性和 Cookie 依赖尚未确认。
|
||||
- 当前 raw `content.custom.data` 会经过 MAIN、ISOLATED、Service Worker、扩展 IndexedDB、Bright/Server JSONB,并最终进入 Mind-facing history/event;本任务必须在 MAIN world 解码并停止这种 raw 跨层传播。
|
||||
- Server 当前已满足数据库提交后 ACK,并且 history 与实时 `message.created` 的 message 对象都来自同一数据库事实;本任务不得破坏该顺序与事实源。
|
||||
- Server 当前 `content jsonb` 足以保存 normalized union,第一阶段没有新增媒体表的技术必要。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. 统一内容合同
|
||||
|
||||
- 消息内容必须使用可判别联合类型表达 `text | image | file`。
|
||||
- 每个 `content` 对象必须自带 `version: 1`;JSONB、HTTP history 和 WS `message.created` 原样使用同一版本字段。
|
||||
- WebSocket `protocolVersion=3` 与 `content.version=1` 是两个独立版本边界;缺失或未知内容版本必须 fail closed。
|
||||
- `content` 是跨插件、Server、Mind 的唯一可写内容事实源。
|
||||
- v3 `OneTalkMessage`、`OneTalkObservedMessage`、history 和实时事件必须移除顶层 `text` 与 `contentType`;所有消费者只读取 `content.kind`。
|
||||
- 合法但暂不支持的业务卡片暂不进入同步业务链路:MAIN world 识别后跳过,不上传、不持久化、不发给 Mind,也不得伪装为文件、文本或空消息。
|
||||
- 跳过必须产生仅含安全类型枚举/计数的 `unsupported_skipped` 诊断,不得包含消息 ID、正文、URL 或 raw payload。
|
||||
- 跳过 unsupported 不得阻塞本批同步;未来纳入支持范围时必须通过 full sync 补回历史消息。
|
||||
|
||||
### R2. 插件解析与上传
|
||||
|
||||
- OneTalk raw payload 只允许在 MAIN world 内短暂存在。
|
||||
- 插件必须完成 Base64、UTF-8、JSON 和字段 schema 校验后再跨页面桥。
|
||||
- 图片和文件必须分别校验判别字段;文件必须同时满足 `cardType=12`。
|
||||
- `cardType=12` 使用通用文件合同,不将实现限制为 ZIP/PDF;文件扩展名必须通过安全字符/长度校验,并与文件名后缀一致。
|
||||
- 未取得真实样本的文件类型可按通用合同同步和下载,但不得根据扩展名猜造 MIME 或启用未经允许的内联预览;运行态“已验证”范围仍只包含 ZIP/PDF。
|
||||
- 历史消息和实时入站消息必须共用同一个 MAIN-world content decoder 和 normalized contract,不得维护两套媒体解析逻辑。
|
||||
- 插件上传给 Server 的是 normalized content,不上传完整 OneTalk raw payload、认证值或未白名单字段。
|
||||
- 图片/附件解析异常必须返回明确 anomaly 或受控失败,不静默降级为空文本。
|
||||
- 判别为图片/附件但 Base64、UTF-8、JSON、大小、字段或 URL 校验失败时,记录脱敏 anomaly,不创建上传 candidate;同批其它消息继续处理,批次结果为 `succeeded_with_anomalies`。
|
||||
- 媒体 anomaly 只记录稳定错误码和安全类型信息,不记录正文、完整 URL、消息 ID 或 raw payload;相同异常必须去重,避免重复刷日志。
|
||||
- Server anchor 只能指向已持久化的有效消息,不能用被跳过的坏消息 ID 伪造推进。
|
||||
- 文本消息现有行为必须保持兼容。
|
||||
- 本任务只支持现有一对一 OneTalk 买家会话;群聊继续返回显式 `skipped`,不得启动群聊历史或 live 媒体处理。
|
||||
|
||||
### R3. Server 持久化
|
||||
|
||||
- Server 必须持久化完整的 normalized content union,并保留消息身份、方向、时间和 ACK/去重语义。
|
||||
- 历史查询与实时事件必须从同一个持久化事实生成,不维护第二套媒体事实源。
|
||||
- 数据库 migration 必须实现已决定的旧 raw 消息清理和会话同步状态重置,不做兼容回填。
|
||||
- URL 缺失时使用 `null`,不使用空字符串或推测值。
|
||||
- 第一阶段复用现有 `content jsonb`;不得仅为媒体展示新增媒体表或平行内容列。
|
||||
- PostgreSQL 必须删除旧 `text` 和 `content_type` 列,`content jsonb` 是唯一内容事实源;不得保留由应用逻辑独立更新的内容投影列。
|
||||
- 必须保持 `DB commit → message.ack → accepted 才发布 message.created` 的既有顺序;duplicate 不重复发布。
|
||||
- 当前版本尚未正式发布,旧 raw 消息视为可丢弃开发数据,不实现 raw-to-normalized 数据迁移器或长期 read-time adapter。
|
||||
- PostgreSQL 升级必须精确清理 OneTalk 旧消息事实和由其派生的消息计数/同步状态,再由 v3 执行全量重同步。
|
||||
- PostgreSQL 清理不得删除账号、binding、联系人 profile 或其它渠道数据。
|
||||
|
||||
### R3.1 扩展同步状态升级
|
||||
|
||||
- 扩展 IndexedDB 必须升级 schema 版本并清理旧 raw message、pending candidate、anchor/checkpoint 等同步状态。
|
||||
- 扩展配置、deviceId、联系人 profile ledger 和其它渠道存储必须保留。
|
||||
- v3 首次启动必须从干净同步状态执行 full sync,不能恢复或上传任何 v2 raw candidate。
|
||||
|
||||
### R4. 发给 Mind 的消息格式
|
||||
|
||||
- Mind history 和实时 `message.created` 必须使用同一个版本化内容合同。
|
||||
- 图片必须提供尺寸、大小、扩展名和预览地址状态。
|
||||
- 文件必须提供文件名、扩展名、大小、预览/缩略图/下载地址及明确的下载可用状态。
|
||||
- `/harness` 对未知文件类型使用通用文件卡片;只有已确认的 URL action 决定预览/下载能力,扩展名不决定动作。
|
||||
- PDF 当前 payload 没有确定下载地址时,必须返回 `downloadUrl=null` 和 `downloadState="not_provided"`。
|
||||
- Mind 不得解析 OneTalk 的 `custom.data`、`msgType/subType` 或 raw SDK payload。
|
||||
- 本任务不修改独立 `trade-mind` 仓库或正式 Mind UI。
|
||||
- 当前仓库 `/harness` 是本任务唯一的 Mind 联调页面:history 与 `message.created` 必须进入同一媒体展示/去重路径。
|
||||
- `/harness` 只承担调试展示和合同验收,不扩展为生产 UI,也不增加媒体发送入口。
|
||||
- `/harness` 必须按 `content.kind` 展示 text/image/file、关键元数据、预览/下载状态和 normalized JSON,不解析 OneTalk `custom.data`。
|
||||
- `/harness` 对图片使用真实 `<img>` 加载 `previewUrl`;加载失败时显示明确错误状态,不隐藏消息卡片。
|
||||
- `/harness` 对文件只在相应 URL 存在时显示预览/下载按钮;文件下载必须由用户点击触发,禁止页面自动下载。
|
||||
- 文件预览/下载打开失败时必须保留文件元数据和错误状态;链接使用安全的新窗口属性。
|
||||
|
||||
### R5. 链接语义
|
||||
|
||||
- 第一阶段允许插件将通过白名单校验的 OneTalk 临时 URL 上传到 Server,Server 持久化并透传给 Mind-facing history/event。
|
||||
- 所有媒体 URL 字段必须可空,并统一标记 `urlScope="onetalk_session"`;合同不承诺跨登录环境、跨 Origin 或长期持久化后仍可访问。
|
||||
- `/harness` 可以直接使用当前有效 URL 做预览或下载联调,但 URL 失败必须显示明确状态,不能显示为空白消息。
|
||||
- 不允许修改 `fileAction` 或其它 query 参数来猜造下载链接。
|
||||
- payload 自带非空 `downloadUrl` 时,经 URL 安全校验后可作为下载候选。
|
||||
- `params.url.fileAction=download` 时,`params.url` 可标记为 payload 提供的下载候选。
|
||||
- `params.url.fileAction=officePreview` 时,它只能标记为预览地址。
|
||||
- `thumbnailUrl` 不能作为下载地址。
|
||||
- URL 不得进入普通日志、anomaly 详情或诊断 payload;只允许出现在受控消息 content 和数据库字段中。
|
||||
- 本任务不增加 Server 媒体代理、临时 URL 换取服务或二进制转发。
|
||||
|
||||
### R6. 兼容与版本
|
||||
|
||||
- `ONETALK_PROTOCOL_VERSION` 必须从 v2 提升为 v3;v3 的 `content` 只表示 normalized `text | image | file`。
|
||||
- v3 Server 不接受 v2 业务同步;旧插件必须得到明确的升级失败状态,不能让 v2 raw 和 v3 normalized 在同一协议语义下共存。
|
||||
- OneTalk 页面必须显示明显的插件升级提示,并提供可信的插件下载链接。
|
||||
- 升级提示不能只存在于扩展 Popup;用户停留在 OneTalk 页面时必须能看到。
|
||||
- 升级提示与下载 URL 不得包含 OneTalk/Bright binding、账号、会话或其它业务认证信息;OSS 签名 query 只允许存在于 URL 本身,不得显示、记录或复制到诊断。
|
||||
- 当前 `0.8.6` 尚未发布,没有已安装旧客户端;本任务直接完成 v3 和页面升级 UI,不发布桥接版本。
|
||||
- Server 在协议升级错误 `ws.error` 中按需提供短期私有 OSS `downloadUrl`,不使用构建变量或稳定下载入口。
|
||||
- 私有 OSS 中的升级安装包是版本化 ZIP,内容为可加载的扩展 `dist/`;本任务不生成或支持 CRX。
|
||||
- 升级错误必须提供目标版本;横幅显示版本号并提示用户下载 ZIP、解压并通过 Chrome“加载已解压的扩展程序”安装。
|
||||
- Server 通过运行时环境变量读取 OSS endpoint、bucket、不可变的版本化 object key、目标插件版本和签名凭证。
|
||||
- 生产环境缺失或存在非法 OSS 升级配置时 Server 必须启动失败,不能静默退化为没有下载地址的升级错误。
|
||||
- 开发和自动测试必须注入 fake signer,不连接真实 OSS,也不要求真实 OSS 凭证。
|
||||
- Server 生成的 OSS `downloadUrl` 有效期固定为 24 小时,并在错误 payload 中同时提供 `expiresAtMs`。
|
||||
- 插件不自动刷新升级下载地址;超过 `expiresAtMs` 后禁用下载按钮并提示重新加载 OneTalk 页面,由页面重新连接获取新地址。
|
||||
- 插件只有在同时收到协议升级错误和合法 `downloadUrl` 时,才在 OneTalk 页面显示升级提示;缺少或非法 URL 时不得显示下载按钮。
|
||||
- 升级提示使用页面顶部固定、醒目的非模态横幅,文案明确说明“插件版本过低,消息同步已停止”,并提供“下载新版本”按钮。
|
||||
- 协议升级阻断期间横幅不可关闭;兼容协议重新认证成功后自动移除。
|
||||
- 横幅不得遮挡、禁用或修改 OneTalk 自身核心交互。
|
||||
- Server 只接受签名结果为绝对 HTTPS、无 userinfo/fragment、host 精确等于配置的 OSS endpoint;插件再次校验 HTTPS、无 userinfo/fragment、ZIP 路径和有效期,不维护第二份 OSS endpoint 配置。
|
||||
- 协议升级错误必须采用旧客户端能够解码的兼容错误边界,不能先因帧版本不匹配丢弃 `downloadUrl`。
|
||||
- 页面使用安全的新窗口链接属性打开下载地址。
|
||||
- OSS bucket、object key、签名 query 和访问凭证不得进入日志、诊断或错误详情。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] 真实 JPEG 历史样本被解析为 `kind="image"`,字段与 OneTalk/SDK 样本一致。
|
||||
- [ ] 真实 ZIP 和 PDF 历史样本被解析为 `kind="file"`,共同满足 `cardType=12`。
|
||||
- [ ] 其它安全扩展名的 `cardType=12` fixture 能通过通用文件合同;扩展名非法或与文件名后缀不一致时产生 anomaly。
|
||||
- [ ] 历史与 live observer 对同一图片/文件 fixture 生成完全相同的 normalized content,并通过同一上传与 ACK 链路。
|
||||
- [ ] `cardType=2000` 样本在 MAIN world 被明确计为 `unsupported_skipped`,不跨页面桥、不入库、不发布且不阻塞同步。
|
||||
- [ ] 非法 Base64、UTF-8、JSON、超限、字段和 URL fixture 均产生预期 anomaly,不创建 candidate,不阻塞同批有效消息,并且诊断不泄露原始内容。
|
||||
- [ ] ZIP 返回 payload 提供的下载候选;PDF 返回 `downloadUrl=null` 和预览地址。
|
||||
- [ ] 插件跨页面桥、IndexedDB 和发往 Server 的 payload 中不包含 raw `custom.data`、`chatToken` 或完整 SDK 对象。
|
||||
- [ ] Server 数据库能够持久化并读取 text/image/file 三类内容。
|
||||
- [ ] Server history 和实时事件为同一条消息返回等价的版本化 content。
|
||||
- [ ] text/image/file 三类 content 均包含 `version=1`;未知或缺失 content version 在共享 decoder 和 Server 边界被拒绝。
|
||||
- [ ] `/harness` 能根据 `content.kind` 区分文本、图片和文件,并验证 history/live 去重与等价,不读取 OneTalk 私有字段。
|
||||
- [ ] `/harness` 能真实加载图片预览;图片失败、文件无下载 URL 或用户打开链接失败时均显示明确状态,不出现空白消息。
|
||||
- [ ] v3 文本消息使用 `content={kind:"text",text}` 完成插件→数据库→history/event→`/harness` round-trip;任何 v3 payload 都不存在顶层 `text/contentType`。
|
||||
- [ ] v3 升级会精确删除旧 raw OneTalk 消息和同步状态,并触发 full sync;配置、deviceId、profile、binding 和其它渠道数据保持不变。
|
||||
- [ ] PostgreSQL migration 删除旧 `text/content_type` 列;Server 所有写入和读取只使用 `content jsonb`。
|
||||
- [ ] 图片/附件 URL 不可用或缺失时,合同返回明确状态,不生成伪造链接或空白消息。
|
||||
- [ ] Server 持久化和 Mind-facing history/event 中的媒体 URL 均为可空字段并标记 `urlScope="onetalk_session"`,普通日志不包含完整 URL。
|
||||
- [ ] 兼容旧协议的插件收到升级 `ws.error` 和合法 OSS `downloadUrl` 后,会在 OneTalk 页面显示明确提示;提示不依赖打开 Popup。
|
||||
- [ ] 升级横幅在协议阻断期间保持可见且不可关闭,不阻断 OneTalk 自身操作;兼容协议认证成功后自动消失。
|
||||
- [ ] Server 未发送 `downloadUrl` 或插件判定 URL 非法时,不展示下载按钮,且不把完整 URL 写入日志或诊断。
|
||||
- [ ] 升级横幅显示目标版本和 ZIP 解压安装说明;下载目标是可加载的 `dist/` ZIP,不是 CRX。
|
||||
- [ ] 升级下载地址在 24 小时后被客户端判定为过期并禁用;重新加载 OneTalk 页面可重新连接并取得新地址,不会继续使用旧签名。
|
||||
- [ ] 升级错误帧能在协议版本不匹配场景被旧客户端解码,不会在读取 `downloadUrl` 前被通用版本校验拒绝。
|
||||
- [ ] 生产 OSS 升级配置缺失或非法时 Server 启动失败;测试 fake signer 能稳定生成不含真实凭证的升级错误 fixture。
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- OneTalk 图片和附件发送、上传文件选择器及发送确认。
|
||||
- 主动下载图片/附件二进制并保存到 Bright 或 Mind。
|
||||
- 群聊文本与媒体同步,以及群聊身份、participant 和路由扩展。
|
||||
- 未取得真实样本的文件类型运行态验收,除非后续明确纳入 MVP。
|
||||
- 独立 `trade-mind` 仓库、正式 Mind 页面及其生产图片/附件 UI。
|
||||
- `apps/mind-http-mock` 的消息存储或媒体展示;它不是消息消费端。
|
||||
|
||||
## Notes
|
||||
|
||||
- 本任务为跨插件、Server、数据库和 Mind 合同的复杂任务;完成 planning 前必须补齐 `design.md` 与 `implement.md`。
|
||||
- 运行态取数和字段合同参考 `docs/onetalk-media-message-sync-prd.md`,但 task PRD 的最终范围以本轮 grilling 决策为准。
|
||||
- 当前没有真实 live 图片/附件 push 样本;实现必须覆盖 live 入口,但真实运行态验收状态只能标记为待样本补证。
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
# OneTalk 媒体同步现状证据
|
||||
|
||||
## 1. 当前事实
|
||||
|
||||
当前系统不是 wire 层拒绝媒体,而是 `content` 为任意 JSON,只有顶层 `text` 具备消费语义。图片、附件和其它卡片的 raw `message.content` 会跨过所有边界并进入数据库与 Mind-facing 输出。
|
||||
|
||||
```text
|
||||
OneTalk message.content
|
||||
→ MAIN observer 完整复制
|
||||
→ page bridge 任意 JSON 转发
|
||||
→ Service Worker / IndexedDB message + candidate
|
||||
→ message.observed
|
||||
→ Server generic sanitizer
|
||||
→ PostgreSQL content JSONB
|
||||
→ HTTP history / message.created
|
||||
```
|
||||
|
||||
## 2. 插件证据
|
||||
|
||||
- `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:13-29,89-115`:observer 的 `content` 是开放 JSON;完整复制 raw content,只额外提取 `content.text.content`。
|
||||
- `apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts:8-33` 与 `history.ts:13-33`:live/history 共用 observer,但没有媒体 decoder。
|
||||
- `apps/chrome-extension/src/onetalk/page-bridge/model.ts:202-208`:页面桥允许任意 JSON 键值继续传播。
|
||||
- `apps/chrome-extension/src/onetalk/page-bridge/isolated.ts:41-75`:ISOLATED 当前只做来源/方向校验和无状态转发。
|
||||
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts:66-78`:Service Worker 再次原样复制 content。
|
||||
- `apps/chrome-extension/src/onetalk/service-worker/storage.ts:267-282,476-494,518-558`:完整消息进入 message 与 candidate store。
|
||||
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine/ack-completion.ts:157-175`:pending candidate 被逐条上传。
|
||||
- `apps/chrome-extension/src/onetalk/service-worker/bright-client.ts:329-342,541-545`:frame 被 JSON 序列化发送给 Server。
|
||||
- `apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts:51-68`:当前开发日志可能记录 raw WebSocket frame,需要移除或改成安全结构诊断。
|
||||
|
||||
## 3. 共享协议与 Server 证据
|
||||
|
||||
- `apps/onetalk-contract/src/model.ts:3,148-154,207-236`:协议固定 v2,message/observed content 是递归 JSON,顶层另有 `contentType` 与 `text`。
|
||||
- `apps/onetalk-contract/src/decoder.ts:229-250,401-414,442-477`:decoder 只验证通用 JSON 和字段类型;非当前版本统一返回升级错误。
|
||||
- `apps/server/src/onetalk/service.ts:45-103,170-243`:Server 递归过滤显式敏感键,但字符串与 Base64 内嵌敏感键无法被识别,raw content 仍会保存。
|
||||
- `apps/server/src/database/schema/onetalk.ts:45-101`:消息幂等键是 `channelAccountId + conversationId + messageId`;内容分散为 `content_type`、`text`、`content jsonb`。
|
||||
- `apps/server/src/onetalk/repository.ts:36-50,95-120,299-358`:Repository 原样写读三份内容;duplicate 只更新 `lastObservedAt`,不会用后到 normalized payload 覆盖旧 raw 行。
|
||||
- `apps/server/src/websocket/handler.ts:881-925`:既有成功顺序是 DB commit → plugin ACK → accepted 才 publish;duplicate 不重复发布。
|
||||
- `apps/server/src/http/onetalk.ts:233-247`:history 返回 `OneTalkMessage[]`。
|
||||
- `apps/server/src/websocket/registry.ts:489-510`:`message.created.payload.message` 使用同一个 DB message。
|
||||
|
||||
## 4. Mind 调试页证据
|
||||
|
||||
- `apps/server/src/http/harness.ts:274-329,349-409,445-452`:`/harness` 加载分页 history 后连接 `/ws/mind`。
|
||||
- `apps/server/src/http/harness.ts:185-221,298-317`:history 与 live message 进入同一 Map,按账号、会话、消息 ID 去重。
|
||||
- `apps/server/src/http/harness.ts:127-151,189-221`:当前 guard 仍接受任意 JSON,展示仅为 `JSON.stringify(content)`,没有媒体语义。
|
||||
- `apps/server/test/onetalk-http.test.ts:622-645`:当前 harness 测试只做静态 HTML 关键字检查,真实媒体展示必须补浏览器 smoke。
|
||||
- `apps/mind-http-mock/src/server.ts:1-25,48-103`:mock 只覆盖 authorization/contact-profile,不是消息消费端,本任务不扩展它。
|
||||
|
||||
## 5. 保留的不变量
|
||||
|
||||
- 幂等键不变:`channelAccountId + conversationId + messageId`。
|
||||
- history 与 `message.created` 的 message 对象来自同一数据库事实。
|
||||
- 数据库提交后才能 ACK,只有 `accepted` 才发布;duplicate 不发布。
|
||||
- anchor 只能指向已持久化的真实消息 ID,不能合成或使用被跳过消息。
|
||||
- `activeAccountId` 只用于定位选中买家,不可作为 `conversationCode`;历史请求必须使用唯一匹配会话的 `conversation.cid`。
|
||||
- 群聊继续显式跳过。
|
||||
|
||||
## 6. 主要风险
|
||||
|
||||
- raw JSON 字符串和 Base64 内容可绕过按 key 递归清洗。
|
||||
- message frame 没有媒体 payload 大小上限,可能放大页面桥、IndexedDB、WS 和 JSONB 压力。
|
||||
- 扩展旧 pending candidate 若不清理,会在升级后继续上传 raw content。
|
||||
- PostgreSQL duplicate 不覆盖旧内容,不能依赖重同步修复旧 raw 行。
|
||||
- 顶层 `contentType`、`text` 与 `content` 是三份可独立写入的内容事实,可能不一致。
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# OSS 私有插件包下载研究
|
||||
|
||||
## 1. 结论
|
||||
|
||||
插件升级 ZIP 放在私有 OSS。Server 使用阿里云 OSS Node.js SDK 为固定、版本化 object key 生成 GET 预签名 URL,并通过协议升级错误发送给插件。
|
||||
|
||||
- 使用 V4 预签名。
|
||||
- 有效期固定 `86400` 秒(24 小时)。
|
||||
- URL 自带临时授权参数,按 bearer-like 凭证处理,不进入日志、诊断或错误详情。
|
||||
- 自动测试和开发环境使用注入的 fake signer,不访问真实 OSS。
|
||||
|
||||
## 2. 官方依据
|
||||
|
||||
- 阿里云 OSS Node.js 文档说明预签名 GET URL 可以在不暴露凭证的情况下授权第三方下载私有对象:<https://help.aliyun.com/en/oss/developer-reference/download-objects-using-a-presigned-url-generated-with-oss-sdk-for-node-js>
|
||||
- 阿里云推荐 V4 预签名,并说明 URL 中包含签名和授权参数;示例有效期可以使用 `86400` 秒:<https://help.aliyun.com/en/oss/developer-reference/add-signatures-to-urls>
|
||||
- 官方 JavaScript SDK:<https://github.com/ali-sdk/ali-oss>
|
||||
|
||||
## 3. 安全边界
|
||||
|
||||
- 只有通过 WebSocket Origin、legacy hello 形状、scope 和 binding 授权的插件连接才能请求升级下载地址。
|
||||
- Server 配置必须提供 endpoint、bucket、不可变 object key、目标扩展版本和凭证;生产缺失时 fail fast。
|
||||
- signer 输出必须是绝对 HTTPS URL,禁止 userinfo 和 fragment,host 必须精确等于配置的 OSS endpoint。
|
||||
- 插件只把完整 URL保存在 Service Worker 内存与目标 tab 的升级横幅中;不得写入 `chrome.storage`、IndexedDB 或 console。
|
||||
- URL 过期后禁用按钮,用户重新加载 OneTalk 页面并重新连接以获得新地址。
|
||||
@@ -0,0 +1,101 @@
|
||||
# OneTalk 媒体运行态合同证据
|
||||
|
||||
## 1. 环境
|
||||
|
||||
- Chromium:Chrome 154,CDP 1.3。
|
||||
- OneTalk PWA:`https://onetalk.alibaba.com/message/weblitePWA.htm`。
|
||||
- Trade Message Center:固定扩展 ID `ogdbffjakeeidblabkeakakdecfbcmlf`,运行态版本 `0.8.6`。
|
||||
- 本轮只读检查前后未读标记、页面消息缓存和 OneTalk 页面 IndexedDB 计数保持不变。
|
||||
|
||||
## 2. 图片
|
||||
|
||||
原始历史合同:
|
||||
|
||||
```text
|
||||
message.content.contentType = 101
|
||||
message.content.custom.type = 7
|
||||
message.content.custom.data = Base64(UTF-8 JSON)
|
||||
```
|
||||
|
||||
SDK 交叉证据:
|
||||
|
||||
```text
|
||||
msgType = 102
|
||||
subType = 60
|
||||
originalData = {
|
||||
fileId: string,
|
||||
suffix: string,
|
||||
size: number,
|
||||
width: number,
|
||||
height: number,
|
||||
isOriginal: number,
|
||||
md5: string,
|
||||
url: string
|
||||
}
|
||||
```
|
||||
|
||||
当前样本为 JPEG,`isOriginal=1`。`originalData.url` 是 `https://clouddisk.alibaba.com/file/redirectFileUrl.htm` 操作地址,query 含 `fileAction=imagePreview`;顶层 SDK `message.content` 不等于该 URL。
|
||||
|
||||
## 3. 文件附件
|
||||
|
||||
原始历史合同:
|
||||
|
||||
```text
|
||||
message.content.contentType = 101
|
||||
message.content.custom.type = 10010
|
||||
message.content.custom.data = Base64(UTF-8 JSON)
|
||||
decoded.cardType = 12
|
||||
```
|
||||
|
||||
SDK 交叉证据:
|
||||
|
||||
```text
|
||||
msgType = 10010
|
||||
subType = 61
|
||||
originalData = {
|
||||
cardType: 12,
|
||||
params: {
|
||||
type: string,
|
||||
ctime: string,
|
||||
version: string,
|
||||
extensionType: string,
|
||||
id: string,
|
||||
parentId: string,
|
||||
md5: string,
|
||||
name: string,
|
||||
size: string,
|
||||
url: string,
|
||||
thumbnailUrl: string,
|
||||
downloadUrl: string
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
两个真实附件样本 schema 一致:
|
||||
|
||||
| 扩展名 | `params.url.fileAction` | `params.downloadUrl` | 语义 |
|
||||
| --- | --- | --- | --- |
|
||||
| ZIP | `download` | 空字符串 | `params.url` 是 payload 提供的下载候选 |
|
||||
| PDF | `officePreview` | 空字符串 | `params.url` 只用于预览,没有确定下载地址 |
|
||||
|
||||
`thumbnailUrl` 路径为 `/file/videoThumb.htm`,不是下载地址。顶层 SDK `message.content` 不等于 `params.url`。
|
||||
|
||||
## 4. 非文件卡片
|
||||
|
||||
同一缓存中存在:
|
||||
|
||||
```text
|
||||
msgType = 10010
|
||||
subType = 2000
|
||||
originalData.cardType = 2000
|
||||
```
|
||||
|
||||
因此 `custom.type/msgType=10010` 不能单独表示附件。用户决定本任务对这些合法但不支持的卡片做安全计数后跳过,不上传、不入库、不发给 Mind。
|
||||
|
||||
## 5. 未确认边界
|
||||
|
||||
- 尚无真实 live 图片/附件 push 样本;实现须复用同一 decoder,但运行态验收待样本。
|
||||
- 未验证 OneTalk 媒体 URL 在无 Cookie、Mind Origin、跨时间条件下的可用性。
|
||||
- 未验证 PDF 的正式下载 URL 获取动作。
|
||||
- 未验证群聊媒体;本任务继续跳过群聊。
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "onetalk-media-message-sync",
|
||||
"name": "onetalk-media-message-sync",
|
||||
"title": "支持 OneTalk 图片与附件消息同步",
|
||||
"description": "将 OneTalk 消息内容升级为协议 v3/content v1 的 text-image-file 合同,覆盖插件解析上传、Server JSONB、Mind-facing history/event、harness 媒体联调和 WS OSS 升级提示。",
|
||||
"status": "planning",
|
||||
"dev_type": "feature",
|
||||
"scope": "apps/chrome-extension, apps/onetalk-contract, apps/server (/harness only for Mind debugging); excludes trade-mind and media binary transfer",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-02",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": null,
|
||||
"relatedFiles": [],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
# OneTalk 图片与附件消息格式调查报告
|
||||
|
||||
> 调查日期:2026-09-01
|
||||
> 调查对象:`https://onetalk.alibaba.com/message/weblitePWA.htm` 以及当前 `trade-message-center` OneTalk 扩展链路
|
||||
> 调查方式:Chromium DevTools Protocol(CDP,`127.0.0.1:9222`)只读运行时探查、历史 WebSocket 帧捕获、已加载 SDK bundle 静态检索、仓库代码追踪
|
||||
> 安全边界:本报告不保存或展示 Cookie、`sid`、`chatToken`、加密账号、签名 URL、消息正文和二进制内容;示例只保留字段名、类型和脱敏结构。
|
||||
|
||||
## 1. 摘要
|
||||
|
||||
OneTalk 的图片和附件并不是另一条独立的同步通道。它们和文本一样出现在历史消息 WebSocket 帧的 `body.userMessageModels[].message` 中,但内容形态不同:
|
||||
|
||||
- 文本:`contentType = 1`,正文在 `content.text.content`。
|
||||
- 图片:`contentType = 101`,`content.custom.type = 7`,`content.custom.data` 是 Base64 编码的 JSON。
|
||||
- 附件:`contentType = 101`,`content.custom.type = 10010`,`content.custom.data` 是 Base64 编码的 JSON。
|
||||
|
||||
当前扩展的传输和持久化边界已经能够保留这些原始内容;非文本消息只会令便利字段 `text` 为 `null`,不会令 `content` 消失。因此“现在只实现 text”的准确含义是:当前没有完成图片/附件的语义投影、Mind 端展示和完整发送适配,而不是 WebSocket 接收层完全收不到媒体。
|
||||
|
||||
本次 CDP 实测在当前登录页面的两个已加载会话中调用了只读历史接口,捕获到一页 20 条消息的历史 WebSocket 帧;现有 `parseOneTalkMessages` 返回了全部 20 条,其中包含一条图片和一条附件。没有点击上传、发送或下载,发送侧结论只来自 SDK 和 bundle 的方法/调用形态分析。
|
||||
|
||||
## 2. 调查范围与证据等级
|
||||
|
||||
### 2.1 已直接验证
|
||||
|
||||
1. Chromium CDP 调试端口 `127.0.0.1:9222` 可用,目标页面是 OneTalk PWA。
|
||||
2. `window.IcbuIM.IMBaaSSDK.default.getMessageService()` 存在,且暴露 `fetchMessagesWithoutUpdateToRead`、`sendImageMessage`、`sendTextMessage`、`sendUIMessages` 等方法。
|
||||
3. 历史读取产生 `code = 200` 的 JSON WebSocket 帧,消息列表位于 `body.userMessageModels`。
|
||||
4. 当前页面真实历史数据中出现:
|
||||
- 图片:`type = 1`、`subType = 60`、`msgType = 102`。
|
||||
- 附件:`type = 1`、`subType = 61`、`msgType = 10010`。
|
||||
5. 对同一帧运行仓库现有 `parseOneTalkMessages` 后,图片和附件仍保留在 `message.content`,但 `message.text` 为 `null`。
|
||||
6. 图片和附件的 `custom.data` 经 Base64 解码后是 JSON,而不是二进制图片或文件本体。
|
||||
|
||||
### 2.2 代码级确认、尚未做完整端到端实测
|
||||
|
||||
- Service Worker 的观察、IndexedDB 写入、Bright 上传和 Bright HTTP 返回均使用通用 JSON `content`,类型上没有把内容限制为文本。
|
||||
- 当前没有真实数据库写入后的 Mind 页面媒体渲染回归测试。
|
||||
- 没有执行真实图片上传、附件上传、发送确认和下载操作,因此不能把 SDK bundle 中的发送能力称为扩展已经支持的能力。
|
||||
|
||||
### 2.3 当前未确认
|
||||
|
||||
- 所有历史 `content.custom.type` 的完整枚举。
|
||||
- 图片/附件 URL 的长期有效期、签名参数和权限续期规则。
|
||||
- 群聊媒体消息是否使用完全相同的 `custom` 结构。
|
||||
- 视频、音频、表情、富文本卡片、商品卡片等其他非文本类型的完整字段契约。
|
||||
|
||||
## 3. OneTalk 消息的三层格式
|
||||
|
||||
### 3.1 外层 WebSocket JSON 帧
|
||||
|
||||
OneTalk WebSocket 返回的历史消息帧形态如下。字段值已抽象为类型,敏感值不展示:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"headers": {
|
||||
"mid": "<message-frame-id>",
|
||||
"sid": "<session-id>",
|
||||
"dt": "<frame-type>"
|
||||
},
|
||||
"body": {
|
||||
"degradeFailover": 0,
|
||||
"hasMore": 1,
|
||||
"nextCursor": 0,
|
||||
"userMessageModels": [
|
||||
{
|
||||
"message": {
|
||||
"cid": "<conversation-id>",
|
||||
"messageId": "<message-id>",
|
||||
"createAt": 0,
|
||||
"sender": {
|
||||
"uid": "<sender-id>"
|
||||
},
|
||||
"content": {
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 7,
|
||||
"data": "<base64-json>"
|
||||
}
|
||||
},
|
||||
"extension": {
|
||||
"basicMessageInfo": "<json-string>",
|
||||
"messageDisplayInfo": "<json-string>",
|
||||
"messageEventInfo": "<json-string>"
|
||||
},
|
||||
"searchableContent": {
|
||||
"summary": "<summary>"
|
||||
}
|
||||
},
|
||||
"readStatus": 0,
|
||||
"msgStatus": 1,
|
||||
"recallFeature": {}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
文本和媒体共享 `message` 外壳。不能通过外层 `type` 或 `messageType` 判断是否为图片;媒体识别必须进入 `message.content`。
|
||||
|
||||
### 3.2 历史同步的 MessagePack 推送
|
||||
|
||||
旧的同步推送帧还会把 `syncPushPackage.data[].data` 作为 Base64 字符串传输:
|
||||
|
||||
```text
|
||||
外层 JSON
|
||||
→ body.syncPushPackage.data[].data
|
||||
→ Base64 解码
|
||||
→ MessagePack 解码
|
||||
→ 数字键对象
|
||||
```
|
||||
|
||||
已验证的 `objectType = 40000` 消息结构包含以下数字路径(仅列出已经和明文 JSON 交叉确认的路径):
|
||||
|
||||
| MessagePack 路径 | 含义 |
|
||||
| ----------------------- | ----------------------------- |
|
||||
| `1.2` | 会话 ID `cid` |
|
||||
| `1.3` | 消息 ID |
|
||||
| `1.5` | 创建时间,Unix 毫秒 |
|
||||
| `1.6.1` | `contentType` |
|
||||
| `1.6.2.1` | 文本内容类型名,例如 `text` |
|
||||
| `1.6.2.2` | 内容类型对应的扩展对象 |
|
||||
| `1.7` | 读取状态 |
|
||||
| `1.8` | 消息读取设置 |
|
||||
| `1.9` | 展示样式 |
|
||||
| `1.10.basicMessageInfo` | JSON 字符串形式的基础消息扩展 |
|
||||
| `1.12` | 记录状态 |
|
||||
|
||||
MessagePack 的数字键本身不携带业务字段名,不能仅凭数组位置推断图片、附件或其他媒体含义。媒体的业务字段名必须由明文 `userMessageModels`、SDK 归一化对象或协议定义交叉确认。
|
||||
|
||||
仓库已有的 [MessagePack 解码器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/sync-push-decoder.ts:1) 能解码数字、字符串、数组、对象、二进制、浮点数和 64 位整数;它目前是通用解码器,不负责把 `content.custom` 转成图片或附件领域对象。
|
||||
|
||||
## 4. 文本消息格式
|
||||
|
||||
文本样本的结构为:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 1,
|
||||
"text": {
|
||||
"content": "<text>",
|
||||
"extension": {
|
||||
"basicMessageInfo": "<json-string>",
|
||||
"messageDisplayInfo": "<json-string>",
|
||||
"messageEventInfo": "<json-string>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
当前仓库将 `content` 整体保留,同时额外提取 `content.text.content` 到便利字段 `text`。这个便利字段只适用于真正带 `content.text.content` 的消息。
|
||||
|
||||
## 5. 图片消息格式
|
||||
|
||||
### 5.1 原始 WebSocket 内容
|
||||
|
||||
本次真实历史帧确认图片消息的原始内容形态为:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 7,
|
||||
"data": "<base64-json>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`custom.data` Base64 解码后是 JSON 对象,已确认字段形态如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"fileId": "<file-id>",
|
||||
"height": 0,
|
||||
"isOriginal": 0,
|
||||
"md5": "<md5>",
|
||||
"size": 0,
|
||||
"suffix": "<suffix>",
|
||||
"url": "<image-url>",
|
||||
"width": 0
|
||||
}
|
||||
```
|
||||
|
||||
这里的 `url` 是图片资源地址;报告不记录实际 URL,因为它可能包含访问签名或其他会话相关信息。`size`、尺寸、后缀和 MD5 是元数据,不是图片二进制本体。
|
||||
|
||||
### 5.2 页面 SDK 归一化形态
|
||||
|
||||
同一类消息经过 OneTalk 页面 SDK 归一化后,观察到:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"subType": 60,
|
||||
"msgType": 102,
|
||||
"viewType": 0,
|
||||
"messageType": "send",
|
||||
"content": "<serialized-content>",
|
||||
"originalData": {
|
||||
"fileId": "<file-id>",
|
||||
"height": 0,
|
||||
"isOriginal": 0,
|
||||
"md5": "<md5>",
|
||||
"size": 0,
|
||||
"suffix": "<suffix>",
|
||||
"url": "<image-url>",
|
||||
"width": 0
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`subType = 60`、`msgType = 102` 是页面 SDK/渲染层的归类结果,不应替换原始 `contentType` 和 `custom.type`。同步事实应继续保留原始内容,归类字段只作为投影依据。
|
||||
|
||||
## 6. 附件消息格式
|
||||
|
||||
### 6.1 原始 WebSocket 内容
|
||||
|
||||
本次真实历史帧确认附件消息的原始内容形态为:
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 10010,
|
||||
"data": "<base64-json>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`custom.data` Base64 解码后是一个卡片/文件描述对象:
|
||||
|
||||
```json
|
||||
{
|
||||
"cardType": 0,
|
||||
"params": {
|
||||
"ctime": "<timestamp-string>",
|
||||
"downloadUrl": "<download-url-or-empty>",
|
||||
"extensionType": "<extension>",
|
||||
"id": "<file-id>",
|
||||
"md5": "<md5>",
|
||||
"name": "<file-name>",
|
||||
"parentId": "<parent-id>",
|
||||
"size": "<size-string>",
|
||||
"thumbnailUrl": "<thumbnail-url>",
|
||||
"type": "<file-type>",
|
||||
"url": "<file-url>",
|
||||
"version": "<version>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次样本中 `downloadUrl` 可以为空,而 `url` 和 `thumbnailUrl` 仍存在。因此附件展示逻辑不能简单地写成“只有 `downloadUrl` 非空才是有效附件”。应根据 `type`/`extensionType` 选择预览方式,并把下载地址缺失视为一个需要按页面 SDK 规则处理的状态。
|
||||
|
||||
### 6.2 页面 SDK 归一化形态
|
||||
|
||||
页面 SDK 对同一类消息的归类为:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": 1,
|
||||
"subType": 61,
|
||||
"msgType": 10010,
|
||||
"viewType": 0,
|
||||
"messageType": "send",
|
||||
"content": "<serialized-content>",
|
||||
"originalData": {
|
||||
"cardType": 0,
|
||||
"params": {
|
||||
"id": "<file-id>",
|
||||
"name": "<file-name>",
|
||||
"size": "<size-string>",
|
||||
"type": "<file-type>",
|
||||
"extensionType": "<extension>",
|
||||
"url": "<file-url>",
|
||||
"thumbnailUrl": "<thumbnail-url>",
|
||||
"downloadUrl": "<download-url-or-empty>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`subType = 61`、`msgType = 10010` 是页面显示/消息模型的归类,不是可以脱离 `custom.type = 10010` 单独使用的稳定事实键。
|
||||
|
||||
## 7. 当前仓库的数据流追踪
|
||||
|
||||
### 7.1 页面观察器
|
||||
|
||||
历史帧由 [历史解析器](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts:12) 交给 `observedMessage()`。在 [消息模型](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:65) 中:
|
||||
|
||||
1. 只要 `message.content` 是对象,就把它作为完整 `content` 保留。
|
||||
2. 从 `content.contentType` 提取 `contentType`。
|
||||
3. 只有 `content.text.content` 是字符串时,才填充 `text`。
|
||||
4. 图片/附件没有 `content.text.content`,所以 `text` 为 `null`。
|
||||
|
||||
因此,当前代码并没有把图片/附件转换成错误的文本,也没有在这一层删除原始媒体对象。
|
||||
|
||||
### 7.2 页面桥与 Service Worker
|
||||
|
||||
[页面桥转换](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts:66) 会复制观察消息的所有 JSON 字段;如果原消息已有 `content`,不会用 `text` 覆盖它。`content` 进入 Service Worker 后仍然是通用 JSON 值。
|
||||
|
||||
### 7.3 Bright 服务与数据库
|
||||
|
||||
[Bright 归一化](/Users/ybf/code/trade-message-center-worktree/apps/server/src/onetalk/service.ts:170) 对 `content` 执行通用 JSON 校验和敏感键过滤,然后将清洗后的 `content` 持久化。当前公共契约中的 [OneTalkMessage](/Users/ybf/code/trade-message-center-worktree/apps/onetalk-contract/src/model.ts:203) 也将 `content` 定义为通用 `OneTalkJsonValue`,没有要求它必须含有 `text`。
|
||||
|
||||
所以现有事实链可以保存:
|
||||
|
||||
```text
|
||||
OneTalk raw content
|
||||
→ page observer content
|
||||
→ page bridge content
|
||||
→ Service Worker durable observation
|
||||
→ Bright content JSON
|
||||
→ Mind history response
|
||||
```
|
||||
|
||||
当前缺少的是在某个明确边界增加媒体语义投影,而不是重新设计这条事实链。
|
||||
|
||||
## 8. 已经可以做到什么
|
||||
|
||||
| 能力 | 当前状态 | 证据/限制 |
|
||||
| ----------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------- |
|
||||
| 接收文本历史消息 | 已验证 | 现有观察器提取 `text` |
|
||||
| 接收图片历史消息 | 已验证 | CDP 实测 `custom.type=7`,现有 parser 保留 `content` |
|
||||
| 接收附件历史消息 | 已验证 | CDP 实测 `custom.type=10010`,现有 parser 保留 `content` |
|
||||
| 保留原始媒体元数据 | 代码已支持 | 通用 JSON `content` 贯穿页面桥、Service Worker、Bright |
|
||||
| 按 `channelAccountId + conversationId + messageId` 幂等保存 | 代码已支持 | 媒体不改变消息业务键 |
|
||||
| 在 Mind 历史接口返回原始媒体 JSON | 代码路径支持 | 尚未做真实 DB 写入和 Mind UI 回归 |
|
||||
| 将图片字段投影为 `imageUrl/width/height` | 当前未实现 | 需要新增共享内容解码器/投影器 |
|
||||
| 将附件字段投影为文件名、大小、预览和下载动作 | 当前未实现 | 需要处理 `downloadUrl` 为空的情况 |
|
||||
| 在 Mind 页面显示图片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 |
|
||||
| 在 Mind 页面显示附件卡片 | 当前未实现 | 当前仓库没有对应媒体渲染契约/组件 |
|
||||
| OneTalk 文本发送 | 已有路径 | `sendUIMessages` 与文本确认逻辑以字符串正文为中心 |
|
||||
| OneTalk 图片发送 | 页面 SDK 有方法 | bundle 观察到 `sendImageMessage({ cid, picUrl })`;扩展未接入完整发送契约 |
|
||||
| OneTalk 本地文件/附件发送 | 页面 bundle 有上传流程 | 涉及 `prepareSendFileWithGroup`、OSS 上传和文件卡片;未做真实上传验证 |
|
||||
| 图片/附件发送确认 | 当前未实现 | 出站确认关联器只按文本内容匹配 |
|
||||
| 下载二进制到 Bright | 当前未实现 | 当前只保存消息 JSON,不保存媒体本体 |
|
||||
| 群聊图片/附件全量同步 | 当前未确认 | 既有历史同步对群聊会话有跳过/不支持边界 |
|
||||
|
||||
## 9. 发送侧调查结果
|
||||
|
||||
### 9.1 图片发送
|
||||
|
||||
当前页面 SDK 原型暴露:
|
||||
|
||||
```text
|
||||
sendImageMessage(input)
|
||||
```
|
||||
|
||||
已加载的 `im-weblite-chat` bundle 中观察到 Native PC 分支调用形态:
|
||||
|
||||
```js
|
||||
messageService.sendImageMessage({
|
||||
cid: conversationId,
|
||||
picUrl: selectedPicture,
|
||||
});
|
||||
```
|
||||
|
||||
这说明 OneTalk 页面具有图片发送入口,但这不等于扩展已经具备图片发送能力。扩展当前页面命令在 [page-command.ts](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts:97) 中要求 `command.content` 是字符串,并交给文本型 `sendUIMessages`;需要新增图片输入契约、SDK 调用和发送确认规则后才能接入。
|
||||
|
||||
### 9.2 文件/附件发送
|
||||
|
||||
bundle 中观察到的文件流程不是“直接把本地文件放进消息 JSON”,而是:
|
||||
|
||||
```text
|
||||
File
|
||||
→ 文件类型/大小判断
|
||||
→ 图片或视频压缩(适用时)
|
||||
→ OSS/云盘上传
|
||||
→ prepareSendFileWithGroup 建立文件关系
|
||||
→ 构造文件卡片消息
|
||||
→ sendMessage / sendUIMessages
|
||||
```
|
||||
|
||||
文件卡片消息中出现过以下业务字段:
|
||||
|
||||
```text
|
||||
fileId
|
||||
fileCardUrl
|
||||
downloadUrl
|
||||
previewUrl
|
||||
fileName / name
|
||||
fileSize / size
|
||||
fileType / materialType
|
||||
md5
|
||||
nodeName
|
||||
```
|
||||
|
||||
这些字段来自页面上传流程的内部模型,不能直接假设它们和历史 `custom.data.params` 的字段名一一相同。发送侧必须先抓取一次真实、用户明确触发的上传网络流程,再固定契约。
|
||||
|
||||
### 9.3 发送确认限制
|
||||
|
||||
当前 [send-observation.ts](/Users/ybf/code/trade-message-center-worktree/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts:11) 的待确认状态以字符串 `content` 和消息时间窗口进行关联。对于图片/附件:
|
||||
|
||||
- 图片通常没有可比较的文本正文。
|
||||
- 附件卡片的 HTML/URL 可能在发送前后发生变化。
|
||||
- `sendImageMessage` 或文件上传回调返回的操作 ID不能直接当作最终消息 ID,必须等待完整的 sent-direction OneTalk 观察消息。
|
||||
|
||||
因此图片/附件发送确认不能简单复用“比较 `text`”的逻辑,也不能仅凭 SDK Promise resolve 就写入 Bright。
|
||||
|
||||
## 10. 推荐的实现边界
|
||||
|
||||
如果后续开始实现,建议保持原始事实与展示投影分离:
|
||||
|
||||
### 10.1 共享内容解码器
|
||||
|
||||
在 `apps/onetalk-contract` 或其最近共同父目录增加一个唯一的内容投影器,输入原始 `content`,输出判别联合:
|
||||
|
||||
```text
|
||||
text
|
||||
image
|
||||
attachment
|
||||
unknown
|
||||
```
|
||||
|
||||
最小识别规则:
|
||||
|
||||
```text
|
||||
content.contentType === 1 && content.text.content 是字符串 → text
|
||||
content.contentType === 101 && content.custom.type === 7 → image
|
||||
content.contentType === 101 && content.custom.type === 10010 → attachment
|
||||
其他 → unknown
|
||||
```
|
||||
|
||||
`custom.data` 必须经过:
|
||||
|
||||
```text
|
||||
Base64 解码 → UTF-8 → JSON.parse → 字段校验
|
||||
```
|
||||
|
||||
不能只看 `contentType=101`,因为它至少同时承载图片和附件。
|
||||
|
||||
### 10.2 原始内容必须继续保留
|
||||
|
||||
投影结果不应替换原始 `content`。推荐消息同时保留:
|
||||
|
||||
```text
|
||||
content 原始 OneTalk JSON,可用于审计、未知类型和未来兼容
|
||||
contentType 原始数字类型
|
||||
media 经过严格校验的可选语义投影
|
||||
text 仅文本便利字段
|
||||
```
|
||||
|
||||
未知类型进入 `unknown`,并保留原始 JSON;不能为了让 UI 正常而把未知媒体伪装成文本。
|
||||
|
||||
### 10.3 URL 与二进制边界
|
||||
|
||||
- 首发只保存消息元数据和页面提供的 URL,不把图片/文件二进制下载到 Bright。
|
||||
- 向 Mind 转发时使用字段白名单,不直接序列化整段 OneTalk 原始对象。
|
||||
- 需要确认 URL 是否带签名、有效期多久、是否需要 Cookie;这些信息本次没有读取,不能预设“永久公开 URL”。
|
||||
- `downloadUrl` 为空时,应明确返回“可预览但不可直接下载”或由页面 SDK 提供刷新动作,不能静默拼接 URL。
|
||||
|
||||
### 10.4 发送侧单独建模
|
||||
|
||||
图片发送和附件发送应分别建模,不把文件上传过程塞进文本 `content` 字段:
|
||||
|
||||
```text
|
||||
send.text
|
||||
send.image
|
||||
send.file
|
||||
```
|
||||
|
||||
每一种发送都必须继续遵循现有三态结果:
|
||||
|
||||
```text
|
||||
confirmed_sent
|
||||
rejected_before_send
|
||||
delivery_unknown
|
||||
```
|
||||
|
||||
只有观察到完整的 sent-direction 消息事实后,才能产生 `confirmed_sent`。
|
||||
|
||||
## 11. 调查中没有做的事情
|
||||
|
||||
为避免对真实账号产生副作用,本次没有:
|
||||
|
||||
- 点击图片发送、文件选择器、上传或下载按钮。
|
||||
- 修改 OneTalk 页面状态、IndexedDB、Cookie、Local Storage 或 Service Worker 数据。
|
||||
- 把任何真实 token、账号加密值、会话 SID、消息正文或完整 URL 写入报告。
|
||||
- 把 SDK bundle 中的内部函数名直接当成稳定公共 API。
|
||||
- 声称图片/附件发送已经端到端成功。
|
||||
- 创建 Trellis task 或修改业务代码。
|
||||
|
||||
## 12. 结论
|
||||
|
||||
当前同步系统已经具备“接收并保存图片/附件原始消息”的基础条件,真正缺口集中在三处:
|
||||
|
||||
1. 将 `content.custom.data` 从 Base64 JSON 解码为经过校验的图片/附件语义对象。
|
||||
2. 在 Bright → Mind 的边界定义媒体字段白名单和 URL 生命周期处理。
|
||||
3. 为图片/附件发送建立独立输入和基于 sent-direction 事实的确认关联。
|
||||
|
||||
因此不需要重写 OneTalk WebSocket、MessagePack 解码器、会话锚点或消息幂等机制。下一次实现应从共享内容投影器和测试样本开始,并把真实上传/发送抓包作为单独的运行时验证步骤。
|
||||
@@ -0,0 +1,220 @@
|
||||
# OneTalk 媒体消息格式二次运行态复核方案
|
||||
|
||||
> 日期:2026-09-02
|
||||
> 性质:方案讨论与只读运行态复核清单,不是 Trellis task,不包含实现承诺
|
||||
> 目标:通过真实运行态证据复核两份调查文档的共同结论、冲突结论和仍不明确的内容
|
||||
|
||||
## 1. 复核对象
|
||||
|
||||
本次复核以以下两份文档为输入:
|
||||
|
||||
1. `/Users/ybf/code/trade-message-center-worktree/docs/onetalk-media-message-format-investigation.md`
|
||||
2. `/Users/ybf/code/trade-message-center-worktree/docs/onetalk-message-content-formats.md`
|
||||
|
||||
复核时必须区分三类结论:
|
||||
|
||||
- **运行态事实**:能够从真实 WebSocket、SDK 返回、页面桥、IndexedDB、Bright 或 Mind 中直接观察。
|
||||
- **代码事实**:能够从当前代码路径确认,但尚未完成真实端到端验证。
|
||||
- **方案决策**:例如 raw content 是否保留、normalized content 放在哪一层。这类结论不能伪装成运行态事实。
|
||||
|
||||
## 2. 安全边界与停止条件
|
||||
|
||||
### 2.1 默认只读
|
||||
|
||||
默认允许:
|
||||
|
||||
- 读取当前 Chromium/CDP 目标、版本和已加载 OneTalk SDK 方法。
|
||||
- 调用 `getConversationListByPagination`。
|
||||
- 调用 `fetchMessagesWithoutUpdateToRead`。
|
||||
- 观察历史 WebSocket 响应和同一次调用的 SDK 归一化结果。
|
||||
- 读取扩展 IndexedDB、Service Worker 状态和 Bright 的只读查询结果。
|
||||
- 在内存中执行 Base64、UTF-8 和 JSON 解码,并只输出字段名、类型、长度和布尔判断。
|
||||
|
||||
默认禁止:
|
||||
|
||||
- 调用 `fetchMessages`、`updateMessageToRead` 或其它会改变已读状态的方法。
|
||||
- 点击发送、上传、下载、文件选择器或会改变页面 store 的操作。
|
||||
- 将 Cookie、SID、token、`chatToken`、加密账号、消息正文、完整 URL、URL query 值或原始 payload 写入文件、日志或报告。
|
||||
- 为了便于复核而修改生产数据、IndexedDB、Local Storage、Service Worker 数据或 Bright 数据库。
|
||||
|
||||
图片/文件发送、真实上传、二进制下载和主动制造 live push 必须作为单独步骤,由用户明确同意后再执行;它们不属于默认只读复核范围。
|
||||
|
||||
### 2.2 立即停止
|
||||
|
||||
出现以下任一情况时停止当前检查并清除未保存的临时输出:
|
||||
|
||||
- DevTools、终端或脚本准备输出认证值、完整 URL 或消息正文。
|
||||
- 所调用的方法实际改变已读状态、会话状态或页面数据。
|
||||
- URL 验证开始返回二进制正文,而当前步骤只获准检查响应元数据。
|
||||
- 无法区分测试账号、真实业务账号或目标会话。
|
||||
|
||||
## 3. 实际调试复核范围
|
||||
|
||||
### 3.1 环境基线
|
||||
|
||||
| 编号 | 检查项 | 需要记录的安全证据 | 完成条件 |
|
||||
| ---- | --------------- | ---------------------------------------------------------- | ------------------------------ |
|
||||
| R01 | Chromium 与 CDP | 浏览器版本、CDP protocol 版本、目标页面 URL 的 origin/path | 能确认本次检查对应 OneTalk PWA |
|
||||
| R02 | 扩展与代码版本 | 扩展版本、仓库 commit、工作树是否有影响复核的未提交改动 | 复核结果能够绑定到唯一代码快照 |
|
||||
| R03 | SDK 方法 | 方法是否存在及函数类型,不记录函数闭包或运行时凭证 | 确认只读历史方法可用 |
|
||||
| R04 | 数据状态 | IndexedDB schema 版本及各表计数,不输出记录正文 | 能区分本次复核前后的已有数据 |
|
||||
|
||||
### 3.2 历史消息原始格式
|
||||
|
||||
至少选择包含下列样本的历史会话;每个样本只使用 `T1`、`I1`、`F1`、`C1` 等本地别名,不在报告中记录真实会话 ID 或消息 ID。
|
||||
|
||||
| 编号 | 样本 | 必须检查 | 不得记录 |
|
||||
| ---- | --------------- | -------------------------------------------------------------------------------- | --------------------------------- |
|
||||
| R10 | 文本 `T1` | `contentType`、`text.content` 类型、`text.extension` 的键名 | 正文、extension 字符串值 |
|
||||
| R11 | 图片 `I1` | `contentType`、`custom.type`、Base64/UTF-8/JSON 是否成立、解码后字段名和字段类型 | MD5、fileId、完整 URL 和 query 值 |
|
||||
| R12 | PDF 文件 `F1` | `custom.type`、`cardType`、`params` 字段名和类型 | 文件名原值、文件 ID、完整 URL |
|
||||
| R13 | 非文件卡片 `C1` | `custom.type`、`cardType`、SDK `subType/msgType` | 卡片业务值和账号信息 |
|
||||
|
||||
每个样本都需要同时保留两种独立、脱敏的证据:
|
||||
|
||||
1. WebSocket 原始 `body.userMessageModels[].message.content` 的结构证据。
|
||||
2. 同一次请求对应的 SDK `list[]` 归一化类型和 `originalData` 字段结构。
|
||||
|
||||
不能只根据 SDK 的 `msgType/subType` 反推 WebSocket 原始类型,也不能只根据 WebSocket 的 `custom.type` 推断业务含义。
|
||||
|
||||
## 4. 冲突结论的第二次确认
|
||||
|
||||
每项冲突必须得到两份相互独立的证据后才能定案。代码引用和同一份运行输出的重复截图不算两份独立证据。
|
||||
|
||||
### C1. `custom.type=10010` 是否等于附件
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 真实 PDF 样本:`custom.type=10010`,解码后 `cardType=12`,SDK 归一化为文件类型。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 真实非文件卡片:同样为 `custom.type=10010`,但解码后 `cardType=2000` 或其它非文件值,且 SDK 不把它归一化为文件。
|
||||
|
||||
**定案条件**
|
||||
|
||||
```text
|
||||
file = contentType=101
|
||||
&& custom.type=10010
|
||||
&& decoded.cardType=12
|
||||
&& decoded.params 通过文件 schema
|
||||
```
|
||||
|
||||
若缺少非文件卡片样本,只能说“当前 PDF 样本是附件”,不能泛化为“所有 `custom.type=10010` 都是附件”。
|
||||
|
||||
### C2. 附件样本的 `cardType` 是 `0` 还是 `12`
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 直接读取 PDF WebSocket 原始 `custom.data` 的解码结果,只记录 `cardType` 数字。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 对照同一条消息的 SDK `originalData.cardType`。
|
||||
|
||||
**定案条件**
|
||||
|
||||
- 两个边界均为 `12`:文档中的 `0` 必须标记为错误或不当占位。
|
||||
- 两个边界不一致:记录为 SDK 转换差异,不能先选择其中一个。
|
||||
|
||||
`cardType` 是判别字段,文档示例不得用 `0` 冒充脱敏占位;未知值应写成 `<number>`。
|
||||
|
||||
### C3. raw content 应继续保留还是在 MAIN world 归一化
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 当前观察器确实复制完整 raw content,Bright 当前清洗器确实对字符串原样放行。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 真实运行链检查证明序列化 JSON 或 Base64 中的敏感键名能够到达页面桥、IndexedDB、Bright 输入或持久化边界中的至少一处。
|
||||
|
||||
**定案条件**
|
||||
|
||||
- 若敏感内容能跨边界:raw content 不得继续作为跨层合同,必须在 MAIN world 白名单解码。
|
||||
- 即使当前样本未穿透,也不能据此证明任意 raw content 安全;还需要负向 fixture 覆盖字符串和 Base64 嵌套敏感键。
|
||||
|
||||
建议的目标不变量是:
|
||||
|
||||
```text
|
||||
raw content 仅在 MAIN world 短暂存在
|
||||
→ 输出 text | image | file | unsupported
|
||||
→ 下游只校验 normalized contract,不再解析 OneTalk raw payload
|
||||
```
|
||||
|
||||
### C4. `content` 是 raw 事实源,还是 normalized 事实源
|
||||
|
||||
这不是单靠抓包能够决定的事实冲突,而是架构决策。运行态复核只需要确认现有消费者分别读取了什么;方案确认需要满足:
|
||||
|
||||
- 跨层只有一个可写内容事实源。
|
||||
- 顶层 `message.text` 如需兼容,只能从 `content.kind === "text"` 派生。
|
||||
- 有效但暂未支持的 card 使用 `kind="unsupported"`,不伪装成文本或文件。
|
||||
- 非法 Base64、非法 JSON、字段错误和超限数据进入明确 anomaly 或受控失败,不静默变成空文本。
|
||||
- 合同含义从 raw 改为 normalized 时必须提升协议版本,不能让同一协议版本同时承载两种语义。
|
||||
|
||||
### C5. 完整 `sourceUrl` 是否可以持久化和发送给 Mind
|
||||
|
||||
**第一次证据**
|
||||
|
||||
- 解析图片 `url`、文件 `url/downloadUrl/thumbnailUrl`,只记录 scheme、host、path 后缀、query 键名和是否为空,不记录 query 值。
|
||||
|
||||
**第二次证据**
|
||||
|
||||
- 分别验证 OneTalk 登录上下文和无登录上下文中的响应状态、跳转次数、最终 host、Cookie 依赖和 CORS;只记录状态与布尔结果。
|
||||
|
||||
**定案条件**
|
||||
|
||||
- 未确认 URL 生命周期、授权依赖和 query 敏感性前,`sourceUrl` 不能同时被定义为必填字段并承诺跨网络/数据库持久化。
|
||||
- 如果 URL query 含短期授权或身份值,第一阶段只同步安全元数据和资源 ID;是否增加临时 URL 换取或受控代理另行决策。
|
||||
- 如果需要实际 GET、重定向跟随或读取二进制,本项必须先获得用户明确同意;默认只允许检查响应元数据。
|
||||
|
||||
## 5. 不明确内容的再次确认
|
||||
|
||||
| 编号 | 当前不明确内容 | 再确认方法 | 定案所需证据 |
|
||||
| ---- | -------------------------------------------- | ----------------------------------------------------- | ------------------------------------------------ |
|
||||
| U01 | 图片/文件 URL 是否依赖 OneTalk Cookie | 登录上下文与干净上下文分别检查响应元数据 | 两种上下文的状态、跳转和 Cookie 依赖结论 |
|
||||
| U02 | URL 有效期 | 同一脱敏样本在不同时间点检查响应元数据,不记录 URL | 至少能观察有效、过期或续期行为中的一种明确规则 |
|
||||
| U03 | URL 能否跨 Origin 给 Mind 使用 | 在 Mind 所在 Origin 做只读请求或实际 `<img>`/链接测试 | CORS、Cookie、跳转和 CSP 的真实结果 |
|
||||
| U04 | `downloadUrl` 为空时 `params.url` 的真实语义 | 页面行为与 SDK/bundle 调用点交叉确认 | 能区分预览、下载、操作入口或临时跳转 |
|
||||
| U05 | TXT 是否与 PDF 同构 | 获取一条真实入站 TXT 历史样本并按 R12 检查 | `cardType`、params schema 和 SDK 归一化结果 |
|
||||
| U06 | live 图片/文件 push 是否与历史同构 | 观察真实入站 live 媒体消息,不主动发送 | live envelope 与 history content 的字段级比较 |
|
||||
| U07 | 群聊媒体是否同构 | 在明确允许的群聊样本上执行只读历史检查 | 群聊 raw content、身份和 participant 结构 |
|
||||
| U08 | `custom.type`/`cardType` 完整枚举 | 对已有历史样本做类型和 cardType 计数,只输出数字集合 | 至少确认当前账号样本范围,不宣称全局完整枚举 |
|
||||
| U09 | `confirmed` 是否等于真实持久化 | 将样本 ACK 与 Bright 数据库行只读对应 | `accepted/duplicate` 与数据库事实的区别 |
|
||||
| U10 | Mind 是否能显示媒体 | 检查真实 history/event 消费和 UI 模型 | 真实渲染结果,不以服务启动或 JSON 可返回代替 |
|
||||
| U11 | MessagePack live/sync push 的媒体路径 | 捕获真实媒体 MessagePack 样本并与明文历史消息交叉对应 | 数字路径和业务字段一一对应,不靠数组位置猜测 |
|
||||
| U12 | 图片/文件发送和确认 | 仅在用户明确授权后执行一次受控发送 | 上传结果、最终 sent-direction 消息和三态发送结果 |
|
||||
|
||||
U01–U04、U06、U07 和 U12 依赖特定运行环境或真实样本;缺少样本时状态必须写为 `blocked_by_sample`,不能写成已验证或默认同构。
|
||||
|
||||
## 6. 复核输出格式
|
||||
|
||||
每个检查项使用以下固定格式记录:
|
||||
|
||||
```md
|
||||
### Rxx / Cx / Uxx:标题
|
||||
|
||||
- 状态:verified | contradicted | not_verified | blocked_by_sample | blocked_by_environment
|
||||
- 环境:浏览器版本、扩展版本、commit
|
||||
- 样本别名:T1 / I1 / F1 / C1
|
||||
- 证据 A:来源边界、字段名、字段类型或布尔结果
|
||||
- 证据 B:独立来源边界、字段名、字段类型或布尔结果
|
||||
- 结论:只描述证据能够支持的范围
|
||||
- 未覆盖:明确列出不能外推的消息类型或场景
|
||||
```
|
||||
|
||||
最终报告必须分别列出:
|
||||
|
||||
1. 两份文档共同成立的结论。
|
||||
2. 经两次独立证据确认的冲突及采用哪一侧。
|
||||
3. 被新证据推翻的原结论。
|
||||
4. 尚未确认、缺样本或缺环境的内容。
|
||||
5. 纯方案决策,不得写成运行态事实的内容。
|
||||
|
||||
## 7. 本轮不做
|
||||
|
||||
- 不修改两份原调查文档。
|
||||
- 不创建 Trellis task、PRD 或实现计划。
|
||||
- 不修改协议、数据库、扩展、Bright 或 Mind 代码。
|
||||
- 不执行图片/文件发送、上传或二进制下载。
|
||||
- 不以静态代码检查代替真实运行态结论。
|
||||
@@ -0,0 +1,432 @@
|
||||
# OneTalk 图片与附件消息同步 PRD
|
||||
|
||||
> 日期:2026-09-02
|
||||
> 状态:Draft
|
||||
> 性质:独立 PRD,不创建 Trellis task,不包含实现授权
|
||||
|
||||
## 1. 背景
|
||||
|
||||
OneTalk 历史消息已经包含图片和附件,但当前跨层合同缺少稳定的媒体语义。消费端不能可靠判断一条消息是图片、附件还是其它业务卡片,也不能确定哪个 URL 用于预览或下载。
|
||||
|
||||
本 PRD 固定三个问题:
|
||||
|
||||
1. 图片和附件从哪里获取。
|
||||
2. 解码后返回什么 TypeScript 类型。
|
||||
3. 如何从 OneTalk 原始消息中安全取得并规范化数据。
|
||||
|
||||
## 2. 目标
|
||||
|
||||
- 在 OneTalk MAIN world 内完成 Base64、UTF-8、JSON 解码和字段校验。
|
||||
- 将图片和附件转换为唯一的 typed content,不让下游继续解析 OneTalk raw payload。
|
||||
- 明确图片预览地址、附件预览地址和附件下载地址的选择规则。
|
||||
- 将合法但暂未支持的业务卡片返回为 `unsupported`,不伪装成附件或文本。
|
||||
- 下载地址缺失时返回 `null`,不拼接、修改或猜造 URL。
|
||||
|
||||
## 3. 非目标
|
||||
|
||||
- 不下载图片或附件二进制。
|
||||
- 不验证 URL 在无登录环境、Mind Origin 或长期存储后的可用性。
|
||||
- 不实现图片或附件发送、上传和发送确认。
|
||||
- 不依赖 `window.__conversationListFullData__`、`msgCache` 等调试全局作为生产合同。
|
||||
- 不把 `activeAccountId` 当作 `conversationCode`。
|
||||
|
||||
## 4. 已确认的运行态事实
|
||||
|
||||
| 类型 | WebSocket 原始判定 | SDK 归一化判定 | 当前样本 |
|
||||
| ---------- | ------------------------------------------------------ | -------------------------------------------- | -------- |
|
||||
| 图片 | `contentType=101`、`custom.type=7` | `msgType=102`、`subType=60` | JPEG |
|
||||
| 文件附件 | `contentType=101`、`custom.type=10010`、`cardType=12` | `msgType=10010`、`subType=61`、`cardType=12` | ZIP、PDF |
|
||||
| 非文件卡片 | `contentType=101`、`custom.type=10010`、`cardType!=12` | 当前样本为 `msgType=10010`、`subType=2000` | 业务卡片 |
|
||||
|
||||
补充事实:
|
||||
|
||||
- 图片 `originalData.url` 的 `fileAction=imagePreview`。
|
||||
- ZIP 附件 `params.url` 的 `fileAction=download`。
|
||||
- PDF 附件 `params.url` 的 `fileAction=officePreview`。
|
||||
- 两条附件的 `params.downloadUrl` 都是空字符串。
|
||||
- `thumbnailUrl` 是缩略图操作地址,不是下载地址。
|
||||
- 顶层 SDK `message.content` 是字符串,但不等于图片 URL 或附件 `params.url`,不能作为媒体资源地址。
|
||||
|
||||
## 5. 获取路径
|
||||
|
||||
### 5.1 会话身份
|
||||
|
||||
```text
|
||||
URL activeAccountId
|
||||
→ 只用于定位当前选中的买家/会话对象
|
||||
→ 从会话对象读取 conversation.cid
|
||||
→ conversation.cid 作为 conversationCode
|
||||
```
|
||||
|
||||
必须满足:
|
||||
|
||||
- `activeAccountId` 不能直接作为 `conversationCode`。
|
||||
- 找不到唯一会话对象或缺少 `cid` 时立即失败,不回退到 URL、当前页面猜测值或其它会话。
|
||||
- 每次历史请求都携带明确的 `conversationCode: conversation.cid`。
|
||||
|
||||
### 5.2 历史消息入口
|
||||
|
||||
当前 Chromium 运行态的 SDK 调用路径是:
|
||||
|
||||
```ts
|
||||
window._imsdk.getMessageService().listMessageWithConversationCodeForHistory({
|
||||
conversationCode: conversation.cid,
|
||||
cursor,
|
||||
count,
|
||||
});
|
||||
```
|
||||
|
||||
该方法是异步触发型入口,直接返回值为 `undefined`。生产适配器不得把函数返回值当作消息列表,而必须等待与本次 `requestId + conversationCode` 对应的历史完成事件或 WebSocket 响应。
|
||||
|
||||
底层已观察到的 WebSocket 方法为:
|
||||
|
||||
```text
|
||||
/r/MessageManager/listUserMessages
|
||||
```
|
||||
|
||||
### 5.3 原始数据路径
|
||||
|
||||
媒体解码器的生产输入来自同次历史响应:
|
||||
|
||||
```text
|
||||
WebSocket response
|
||||
→ body.userMessageModels[]
|
||||
→ item.message
|
||||
→ item.message.content
|
||||
```
|
||||
|
||||
字段路径:
|
||||
|
||||
```text
|
||||
图片:
|
||||
message.content.contentType
|
||||
message.content.custom.type
|
||||
message.content.custom.data
|
||||
|
||||
附件:
|
||||
message.content.contentType
|
||||
message.content.custom.type
|
||||
message.content.custom.data
|
||||
→ Base64 decode
|
||||
→ UTF-8 decode
|
||||
→ JSON.parse
|
||||
→ cardType / params
|
||||
```
|
||||
|
||||
### 5.4 SDK 交叉校验路径
|
||||
|
||||
SDK 归一化对象只用于同条消息的类型和字段交叉校验:
|
||||
|
||||
```text
|
||||
图片:
|
||||
sdkMessage.msgType
|
||||
sdkMessage.subType
|
||||
sdkMessage.originalData
|
||||
|
||||
附件:
|
||||
sdkMessage.msgType
|
||||
sdkMessage.subType
|
||||
sdkMessage.originalData.cardType
|
||||
sdkMessage.originalData.params
|
||||
```
|
||||
|
||||
生产实现不得只根据 `msgType=10010` 判断附件,因为相同 `msgType` 还包含 `cardType=2000` 的非文件卡片。
|
||||
|
||||
## 6. 返回数据类型
|
||||
|
||||
```ts
|
||||
type OneTalkUrlScope = "onetalk_session";
|
||||
|
||||
type OneTalkImageContent = {
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5: string | null;
|
||||
previewUrl: string;
|
||||
downloadUrl: null;
|
||||
urlScope: OneTalkUrlScope;
|
||||
};
|
||||
|
||||
type OneTalkFileContent = {
|
||||
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: OneTalkUrlScope;
|
||||
};
|
||||
|
||||
type OneTalkUnsupportedContent = {
|
||||
kind: "unsupported";
|
||||
sourceContentType: number;
|
||||
sourceCustomType: number | null;
|
||||
cardType: number | null;
|
||||
reason: "unsupported_card";
|
||||
};
|
||||
|
||||
type OneTalkNormalizedMediaContent =
|
||||
OneTalkImageContent | OneTalkFileContent | OneTalkUnsupportedContent;
|
||||
|
||||
type OneTalkMediaDecodeResult =
|
||||
| {
|
||||
status: "decoded";
|
||||
content: OneTalkNormalizedMediaContent;
|
||||
}
|
||||
| {
|
||||
status: "invalid";
|
||||
reason:
|
||||
| "invalid_base64"
|
||||
| "invalid_utf8"
|
||||
| "invalid_json"
|
||||
| "invalid_schema"
|
||||
| "payload_too_large";
|
||||
};
|
||||
```
|
||||
|
||||
合同约束:
|
||||
|
||||
- URL 字段统一返回 `string | null`,不使用空字符串表达缺失。
|
||||
- URL 是 OneTalk 登录上下文中的临时操作地址,`urlScope` 固定为 `onetalk_session`。
|
||||
- 第一阶段不得承诺这些 URL 能在 Mind、无 Cookie 环境或长期持久化后直接访问。
|
||||
- `content` 是唯一事实源;兼容字段 `message.text` 只能由 `content.kind === "text"` 派生。
|
||||
|
||||
## 7. 怎么取图片数据
|
||||
|
||||
### 7.1 判定
|
||||
|
||||
```ts
|
||||
content.contentType === 101 && content.custom.type === 7;
|
||||
```
|
||||
|
||||
SDK 交叉校验:
|
||||
|
||||
```ts
|
||||
sdkMessage.msgType === 102 && sdkMessage.subType === 60;
|
||||
```
|
||||
|
||||
### 7.2 解码路径
|
||||
|
||||
```text
|
||||
content.custom.data
|
||||
→ 校验 Base64 字符串和最大长度
|
||||
→ Base64 解码为 bytes
|
||||
→ TextDecoder("utf-8", { fatal: true })
|
||||
→ JSON.parse
|
||||
→ 校验图片 schema
|
||||
```
|
||||
|
||||
解码后的字段:
|
||||
|
||||
```ts
|
||||
type OneTalkImagePayload = {
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: 0 | 1;
|
||||
md5: string;
|
||||
url: string;
|
||||
};
|
||||
```
|
||||
|
||||
映射规则:
|
||||
|
||||
```ts
|
||||
return {
|
||||
kind: "image",
|
||||
fileId: payload.fileId,
|
||||
extension: payload.suffix.toLowerCase(),
|
||||
sizeBytes: payload.size,
|
||||
width: payload.width,
|
||||
height: payload.height,
|
||||
isOriginal: payload.isOriginal === 1,
|
||||
md5: payload.md5 || null,
|
||||
previewUrl: payload.url,
|
||||
downloadUrl: null,
|
||||
urlScope: "onetalk_session",
|
||||
};
|
||||
```
|
||||
|
||||
图片 URL 必须是绝对 HTTPS URL,当前允许的运行态 host 为 `clouddisk.alibaba.com`,当前动作是 `fileAction=imagePreview`。
|
||||
|
||||
## 8. 怎么取附件数据
|
||||
|
||||
### 8.1 判定
|
||||
|
||||
```ts
|
||||
content.contentType === 101 && content.custom.type === 10010 && decoded.cardType === 12;
|
||||
```
|
||||
|
||||
SDK 交叉校验:
|
||||
|
||||
```ts
|
||||
sdkMessage.msgType === 10010 &&
|
||||
sdkMessage.subType === 61 &&
|
||||
sdkMessage.originalData.cardType === 12;
|
||||
```
|
||||
|
||||
### 8.2 解码后的字段
|
||||
|
||||
```ts
|
||||
type OneTalkFilePayload = {
|
||||
cardType: 12;
|
||||
params: {
|
||||
type: string;
|
||||
ctime: string;
|
||||
version: string;
|
||||
extensionType: string;
|
||||
id: string;
|
||||
parentId: string;
|
||||
md5: string;
|
||||
name: string;
|
||||
size: string;
|
||||
url: string;
|
||||
thumbnailUrl: string;
|
||||
downloadUrl: string;
|
||||
};
|
||||
};
|
||||
```
|
||||
|
||||
### 8.3 文件字段映射
|
||||
|
||||
```text
|
||||
fileId ← params.id
|
||||
parentId ← params.parentId
|
||||
fileName ← params.name
|
||||
extension ← params.extensionType
|
||||
sizeBytes ← 十进制解析 params.size
|
||||
md5 ← params.md5 或 null
|
||||
thumbnailUrl ← 非空 params.thumbnailUrl,否则 null
|
||||
```
|
||||
|
||||
必须校验:
|
||||
|
||||
- `params.name` 的后缀和 `params.extensionType` 一致。
|
||||
- `params.size` 是十进制非负整数字符串,转换后是安全整数。
|
||||
- 所有非空 URL 都是绝对 HTTPS URL并通过 host allowlist。
|
||||
- 文件名只能作为文本展示,不能作为 HTML。
|
||||
|
||||
## 9. 附件预览与下载地址规则
|
||||
|
||||
先解析 URL 的 `fileAction`,但不得修改原 URL。
|
||||
|
||||
```ts
|
||||
function selectFileAccessUrls(params: OneTalkFilePayload["params"]): {
|
||||
previewUrl: string | null;
|
||||
thumbnailUrl: string | null;
|
||||
downloadUrl: string | null;
|
||||
downloadState: "available" | "not_provided";
|
||||
} {
|
||||
const sourceAction = readValidatedFileAction(params.url);
|
||||
const explicitDownloadUrl = normalizeOptionalHttpsUrl(params.downloadUrl);
|
||||
|
||||
const downloadUrl = explicitDownloadUrl ?? (sourceAction === "download" ? params.url : null);
|
||||
|
||||
const previewUrl = sourceAction === "officePreview" ? params.url : null;
|
||||
|
||||
return {
|
||||
previewUrl,
|
||||
thumbnailUrl: normalizeOptionalHttpsUrl(params.thumbnailUrl),
|
||||
downloadUrl,
|
||||
downloadState: downloadUrl ? "available" : "not_provided",
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
确定规则:
|
||||
|
||||
- `params.downloadUrl` 非空且通过 URL 校验时,优先作为下载地址。
|
||||
- `params.downloadUrl` 为空且 `params.url.fileAction === "download"` 时,使用 `params.url`。
|
||||
- `params.url.fileAction === "officePreview"` 时,它只是预览地址,`downloadUrl` 返回 `null`。
|
||||
- `thumbnailUrl` 永远不能作为下载地址。
|
||||
- 不允许把 `officePreview`、`imagePreview` 改成 `download` 来猜造下载链接。
|
||||
|
||||
当前样本结果:
|
||||
|
||||
| 文件类型 | `params.url.fileAction` | `params.downloadUrl` | 返回 `downloadUrl` |
|
||||
| -------- | ----------------------- | -------------------- | ------------------ |
|
||||
| ZIP | `download` | 空 | `params.url` |
|
||||
| PDF | `officePreview` | 空 | `null` |
|
||||
|
||||
## 10. 非文件卡片
|
||||
|
||||
以下内容不是附件:
|
||||
|
||||
```ts
|
||||
content.contentType === 101 && content.custom.type === 10010 && decoded.cardType !== 12;
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```ts
|
||||
{
|
||||
status: "decoded",
|
||||
content: {
|
||||
kind: "unsupported",
|
||||
sourceContentType: 101,
|
||||
sourceCustomType: 10010,
|
||||
cardType: decoded.cardType,
|
||||
reason: "unsupported_card",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
不得返回 `file`,也不得降级为空文本。
|
||||
|
||||
## 11. 数据流
|
||||
|
||||
```text
|
||||
Service Worker history request
|
||||
→ MAIN world resolves exact conversation.cid
|
||||
→ OneTalk history SDK request
|
||||
→ /r/MessageManager/listUserMessages WebSocket response
|
||||
→ body.userMessageModels[].message.content
|
||||
→ media decoder
|
||||
→ image | file | unsupported
|
||||
→ page bridge typed result
|
||||
→ IndexedDB / Bright / Mind consumers only read normalized content
|
||||
```
|
||||
|
||||
Raw `content.custom.data` 只能在 MAIN world 短暂存在。页面桥、IndexedDB、Bright 和 Mind 不得继续解析或持久化 OneTalk raw payload。
|
||||
|
||||
## 12. 错误处理
|
||||
|
||||
- 非法 Base64:`invalid_base64`。
|
||||
- UTF-8 解码失败:`invalid_utf8`。
|
||||
- JSON 解析失败:`invalid_json`。
|
||||
- 字段缺失、类型错误、数值溢出、URL 不合法:`invalid_schema`。
|
||||
- 输入超过固定上限:`payload_too_large`。
|
||||
- 合法但不支持的卡片:返回 `unsupported`,不是 `invalid`。
|
||||
- 任一失败都不得静默变为空文本、空 URL 或猜测的附件。
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
- JPEG 样本被规范化为 `kind="image"`,字段与 SDK `originalData` 一致。
|
||||
- ZIP、PDF 样本都被规范化为 `kind="file"`,共同满足 `cardType=12`。
|
||||
- ZIP 返回 payload 自带的下载 URL。
|
||||
- PDF 当前返回 `downloadUrl=null`、`previewUrl=params.url`。
|
||||
- `cardType=2000` 返回 `unsupported`,不识别为文件。
|
||||
- 顶层 SDK `message.content` 不作为图片或附件 URL。
|
||||
- `activeAccountId` 不作为 `conversationCode`;请求使用唯一匹配会话的 `conversation.cid`。
|
||||
- SDK 入口返回 `undefined` 时,适配器仍通过异步响应边界得到正确消息页,不读取同步返回值。
|
||||
- URL 缺失或动作不支持时返回明确状态,不改写 query 参数。
|
||||
- 页面桥之后只存在 normalized union,不包含 raw `custom.data`、认证值或完整 SDK payload。
|
||||
|
||||
## 14. 尚未确认
|
||||
|
||||
- 图片是否存在 payload 自带的独立下载 URL。
|
||||
- PDF 是否有页面 SDK 提供的正式“获取下载 URL”动作。
|
||||
- URL 是否依赖 OneTalk Cookie、有效期多长,以及能否在 Mind Origin 使用。
|
||||
- TXT、DOCX、XLSX 等附件是否与 ZIP/PDF 完全同构。
|
||||
- live 图片/附件 push 是否与历史消息使用相同 raw schema。
|
||||
@@ -0,0 +1,904 @@
|
||||
# OneTalk 消息内容原始格式与媒体同步能力调查报告
|
||||
|
||||
> 调查日期:2026-09-02
|
||||
>
|
||||
> 适用页面:`https://onetalk.alibaba.com/message/weblitePWA.htm`
|
||||
>
|
||||
> 调试环境:Chromium `154.0.8012.0`,CDP `127.0.0.1:9222`,Trade Message Center `0.8.6`
|
||||
>
|
||||
> 调查方式:通过 Chromium CDP 观察真实 OneTalk WebSocket 响应、调用页面只读历史 SDK,并对照扩展 IndexedDB、共享协议和 Bright 存储代码。调查过程中没有发送消息、没有调用会改变已读状态的 API,也没有记录正文、账号、token、完整 URL 或 URL 查询参数值。
|
||||
|
||||
## 1. 结论摘要
|
||||
|
||||
当前系统并不是完全没有采集图片和附件,而是只实现了文本的**语义化解析**:
|
||||
|
||||
- 文本能够从 `content.text.content` 提取为 `message.text`。
|
||||
- 图片和附件能够作为原始 `contentType=101/custom` JSON 被观察、写入 IndexedDB,并通过 Bright ACK。
|
||||
- 图片和附件没有规范化的 `kind`、文件名、扩展名、大小、宽高、缩略图或下载地址合同。
|
||||
- 消费端如果只读取 `message.text`,就会表现为“文本存在,图片和附件不存在”。
|
||||
|
||||
真实样本已确认以下映射:
|
||||
|
||||
| 业务类型 | WebSocket 原始类型 | Base64 解码后的判定 | OneTalk SDK 归一化类型 |
|
||||
| ------------ | ------------------------------------ | -------------------- | --------------------------- |
|
||||
| 文本 | `contentType=1` | `text.content` | `msgType=101, subType=1` |
|
||||
| 图片 | `contentType=101, custom.type=7` | 图片元数据对象 | `msgType=102, subType=60` |
|
||||
| 文件附件 | `contentType=101, custom.type=10010` | `cardType=12` | `msgType=10010, subType=61` |
|
||||
| 其它业务卡片 | `contentType=101, custom.type=10010` | 例如 `cardType=2000` | 不是附件 |
|
||||
|
||||
本次真实附件样本为 PDF;没有抓到 TXT 附件、实时图片 push 或实时文件 push,因此这些场景不能标记为已验证。
|
||||
|
||||
另有一个必须优先修复的安全问题:当前文本内容的 `text.extension.basicMessageInfo` 是一段序列化 JSON,真实样本中包含 `chatToken` 键。页面观察器会复制整个原始 `content`,而 Bright 的清洗器对字符串直接原样放行,因此嵌套在字符串或 Base64 中的敏感字段可能进入持久化。
|
||||
|
||||
## 2. 调查范围与证据边界
|
||||
|
||||
### 2.1 使用的 Chromium/CDP 启动方式
|
||||
|
||||
```bash
|
||||
/Applications/Chromium.app/Contents/MacOS/Chromium \
|
||||
--remote-debugging-address=127.0.0.1 \
|
||||
--remote-debugging-port=9222 \
|
||||
--user-data-dir="/Users/ybf/work/demo-playwright/.chromium-profile"
|
||||
```
|
||||
|
||||
CDP 确认:
|
||||
|
||||
```text
|
||||
Browser: Chrome/154.0.8012.0
|
||||
Protocol-Version: 1.3
|
||||
```
|
||||
|
||||
### 2.2 只读历史方法
|
||||
|
||||
调查只调用了页面现有的只读方法:
|
||||
|
||||
```js
|
||||
const sdk = window.IcbuIM.IMBaaSSDK.default;
|
||||
const conversationService = sdk.getConversationServiceV2();
|
||||
const messageService = sdk.getMessageService();
|
||||
|
||||
await conversationService.getConversationListByPagination({
|
||||
cursor: 0,
|
||||
count: 20,
|
||||
});
|
||||
|
||||
await messageService.fetchMessagesWithoutUpdateToRead(options, conversation);
|
||||
```
|
||||
|
||||
没有调用以下会改变已读状态或页面 store 的方法:
|
||||
|
||||
```text
|
||||
fetchMessages
|
||||
updateMessageToRead
|
||||
```
|
||||
|
||||
### 2.3 本次运行态证据
|
||||
|
||||
本次调查完成时,扩展 IndexedDB 快照为:
|
||||
|
||||
```text
|
||||
数据库:trade-message-center
|
||||
版本:4
|
||||
|
||||
onetalk_messages:135
|
||||
contentType=1:125
|
||||
contentType=101/custom.type=7:1
|
||||
contentType=101/custom.type=10010:9
|
||||
|
||||
onetalk_sync_candidates:135
|
||||
confirmed:135
|
||||
anomaly:0
|
||||
|
||||
onetalk_sync_checkpoints:2
|
||||
phase=uploading:2
|
||||
syncResult=succeeded:2
|
||||
```
|
||||
|
||||
注意:
|
||||
|
||||
- `custom.type=10010` 同时包含文件和非文件业务卡片,不能把 9 条全部解释为附件。
|
||||
- `confirmed` 表示插件已经收到 Bright 的 `accepted` 或 `duplicate` ACK,不表示 Mind UI 已经能渲染媒体。
|
||||
- checkpoint 仍为 `uploading`,说明同步完成/锚点最终收敛是另一个独立问题;它不改变本报告对消息内容格式的判断。
|
||||
|
||||
## 3. OneTalk 历史 WebSocket 原始响应
|
||||
|
||||
### 3.1 顶层 envelope
|
||||
|
||||
真实 WebSocket 历史响应结构如下。所有业务值都已省略:
|
||||
|
||||
```json
|
||||
{
|
||||
"headers": {
|
||||
"<protocol-header>": "<redacted>"
|
||||
},
|
||||
"code": 200,
|
||||
"body": {
|
||||
"degradeFailover": "<value>",
|
||||
"hasMore": 1,
|
||||
"nextCursor": "<cursor>",
|
||||
"userMessageModels": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次网络原帧中的分页字段为:
|
||||
|
||||
```text
|
||||
body.hasMore:0 | 1
|
||||
body.nextCursor:number | null
|
||||
```
|
||||
|
||||
### 3.2 单条历史消息包装
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"cid": "<raw-conversation-id>",
|
||||
"messageId": "<raw-message-id>",
|
||||
"createAt": 0,
|
||||
"content": {},
|
||||
"displayStyle": "<value>",
|
||||
"extension": "<value>",
|
||||
"msgReadStatusSetting": "<value>",
|
||||
"receiverCount": 0,
|
||||
"receivers": [],
|
||||
"redPointPolicy": "<value>",
|
||||
"searchableContent": "<value>",
|
||||
"sender": {
|
||||
"uid": "<participant-id>"
|
||||
},
|
||||
"unreadCount": 0
|
||||
},
|
||||
"msgStatus": 1,
|
||||
"readStatus": 2,
|
||||
"recallFeature": "<value>",
|
||||
"userExtension": "<value>"
|
||||
}
|
||||
```
|
||||
|
||||
当前页面观察器正是从这一层读取:
|
||||
|
||||
```text
|
||||
message.cid
|
||||
message.messageId
|
||||
message.createAt
|
||||
message.content
|
||||
message.sender.uid
|
||||
message.unreadCount
|
||||
item.msgStatus
|
||||
item.readStatus
|
||||
```
|
||||
|
||||
## 4. OneTalk SDK 返回结构
|
||||
|
||||
同一次请求在 SDK Promise 中返回的不是 WebSocket 原始 envelope,而是 SDK 归一化后的扁平结果:
|
||||
|
||||
```json
|
||||
{
|
||||
"hasMore": true,
|
||||
"list": [],
|
||||
"nextCursor": 0
|
||||
}
|
||||
```
|
||||
|
||||
这里的字段类型与网络原帧不同:
|
||||
|
||||
```text
|
||||
SDK hasMore:boolean
|
||||
WebSocket hasMore:0 | 1
|
||||
```
|
||||
|
||||
SDK `list[]` 条目确认包含:
|
||||
|
||||
```text
|
||||
autoReply
|
||||
contact
|
||||
contactRead
|
||||
content
|
||||
conversationCode
|
||||
extInfo
|
||||
localExt
|
||||
messageId
|
||||
messageType
|
||||
msgType
|
||||
opId
|
||||
originExt
|
||||
originalData
|
||||
owner
|
||||
receiver
|
||||
sendTime
|
||||
sender
|
||||
spamStatus
|
||||
status
|
||||
subType
|
||||
type
|
||||
unread
|
||||
uuid
|
||||
viewType
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `content` 已经被 SDK 转成展示字符串。
|
||||
- 媒体的结构化字段位于 `originalData`。
|
||||
- 现有扩展历史观察链使用 WebSocket 原始消息作为事实来源,不应直接切换为 SDK 扁平消息并替换现有消息 ID。
|
||||
- 更安全的做法是直接解码 WebSocket `custom.data`,从而保留当前 `channelAccountId + conversationId + messageId` 幂等边界。
|
||||
|
||||
## 5. 文本原始格式
|
||||
|
||||
### 5.1 WebSocket 原始 content
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 1,
|
||||
"text": {
|
||||
"content": "<message-text-redacted>",
|
||||
"extension": {
|
||||
"basicMessageInfo": "<serialized-json-string>",
|
||||
"messageDisplayInfo": "<serialized-json-string>",
|
||||
"messageEventInfo": "<serialized-json-string>"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 5.2 SDK 归一化结果
|
||||
|
||||
```text
|
||||
msgType:101
|
||||
type:1
|
||||
subType:1
|
||||
viewType:0
|
||||
content:string
|
||||
originalData keys:text
|
||||
```
|
||||
|
||||
### 5.3 当前可以安全保留的字段
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "text",
|
||||
"text": "<message-text>"
|
||||
}
|
||||
```
|
||||
|
||||
不应跨页面边界发送:
|
||||
|
||||
```text
|
||||
text.extension
|
||||
basicMessageInfo
|
||||
messageDisplayInfo
|
||||
messageEventInfo
|
||||
```
|
||||
|
||||
真实 `basicMessageInfo` 字符串解析后包含 `chatToken` 键,因此不能把这段字符串当作普通文本保存。
|
||||
|
||||
## 6. 图片原始格式
|
||||
|
||||
### 6.1 WebSocket 原始 content
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 7,
|
||||
"data": "<base64-json>",
|
||||
"degrade": "",
|
||||
"summary": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次图片样本:
|
||||
|
||||
```text
|
||||
custom.data 字符长度:532
|
||||
Base64 解码后 UTF-8 长度:397
|
||||
Base64 解码结果:JSON object
|
||||
```
|
||||
|
||||
### 6.2 Base64 解码后的 JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"fileId": "<file-id>",
|
||||
"height": 1188,
|
||||
"isOriginal": 1,
|
||||
"md5": "<md5>",
|
||||
"size": 370256,
|
||||
"suffix": "jpeg",
|
||||
"url": "<https-action-url>",
|
||||
"width": 1192
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 SDK 归一化结果
|
||||
|
||||
```text
|
||||
msgType:102
|
||||
type:1
|
||||
subType:60
|
||||
viewType:0
|
||||
```
|
||||
|
||||
SDK `originalData` 的字段与 Base64 解码结果一致:
|
||||
|
||||
```text
|
||||
fileId
|
||||
height
|
||||
isOriginal
|
||||
md5
|
||||
size
|
||||
suffix
|
||||
url
|
||||
width
|
||||
```
|
||||
|
||||
### 6.4 可实现的规范化图片合同
|
||||
|
||||
```ts
|
||||
type OneTalkImageContent = {
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
};
|
||||
```
|
||||
|
||||
规范化规则:
|
||||
|
||||
- `custom.type` 必须为 `7`。
|
||||
- `custom.data` 必须是有大小上限的合法 Base64。
|
||||
- Base64 解码结果必须是 UTF-8 JSON object。
|
||||
- `fileId`、`suffix`、`url` 必须为非空字符串。
|
||||
- `size`、`width`、`height` 必须为有限非负整数,并设置合理上限。
|
||||
- `isOriginal` 的 `0/1` 显式转换为 boolean。
|
||||
- `url` 只接受绝对 HTTPS URL,并在确认真实主机后加入固定 host allowlist。
|
||||
- 不根据 `suffix` 猜造 OneTalk 未提供的 MIME;UI 可以使用安全扩展名映射做展示提示。
|
||||
|
||||
## 7. 文件附件原始格式
|
||||
|
||||
### 7.1 WebSocket 原始 content
|
||||
|
||||
```json
|
||||
{
|
||||
"contentType": 101,
|
||||
"custom": {
|
||||
"type": 10010,
|
||||
"data": "<base64-json>",
|
||||
"degrade": "",
|
||||
"summary": "",
|
||||
"title": ""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
本次 PDF 样本:
|
||||
|
||||
```text
|
||||
custom.data 字符长度:912
|
||||
Base64 解码后 UTF-8 长度:680
|
||||
Base64 解码结果:JSON object
|
||||
```
|
||||
|
||||
### 7.2 Base64 解码后的 JSON
|
||||
|
||||
```json
|
||||
{
|
||||
"cardType": 12,
|
||||
"params": {
|
||||
"ctime": "<epoch-string>",
|
||||
"downloadUrl": "",
|
||||
"extensionType": "pdf",
|
||||
"id": "<file-id>",
|
||||
"md5": "<md5>",
|
||||
"name": "<redacted>.pdf",
|
||||
"parentId": "<parent-id>",
|
||||
"size": "<decimal-string>",
|
||||
"thumbnailUrl": "<https-action-url>",
|
||||
"type": "<value>",
|
||||
"url": "<https-action-url>",
|
||||
"version": "<value>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
重要事实:
|
||||
|
||||
- 文件判定不能只看 `custom.type=10010`。
|
||||
- 真实附件还需要 `cardType=12`。
|
||||
- `params.size` 是 decimal string,不是 number。
|
||||
- `params.downloadUrl` 在本次样本中为空。
|
||||
- 实际可用候选位于 `params.url`;`thumbnailUrl` 是另一个操作地址。
|
||||
- URL 路径表现为 `.htm` 操作入口,并带有 `appkey`、`fileAction`、`id`、`parentId`、`scene`、`secOperateAliId` 等查询键。报告没有记录查询值。
|
||||
|
||||
### 7.3 SDK 归一化结果
|
||||
|
||||
```text
|
||||
msgType:10010
|
||||
type:1
|
||||
subType:61
|
||||
viewType:0
|
||||
originalData.cardType:12
|
||||
originalData.params.extensionType:pdf
|
||||
```
|
||||
|
||||
### 7.4 可实现的规范化文件合同
|
||||
|
||||
```ts
|
||||
type OneTalkFileContent = {
|
||||
kind: "file";
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
};
|
||||
```
|
||||
|
||||
规范化规则:
|
||||
|
||||
- `contentType` 必须为 `101`。
|
||||
- `custom.type` 必须为 `10010`。
|
||||
- Base64 JSON 的 `cardType` 必须为 `12`。
|
||||
- `params.id`、`params.name`、`params.extensionType`、`params.size`、`params.url` 必须通过边界校验。
|
||||
- `params.size` 只接受十进制正整数字符串,并转换为安全整数。
|
||||
- `name` 后缀和 `extensionType` 不一致时 fail closed 或降级为 unsupported,不静默选择其中一个。
|
||||
- `url`、非空的 `downloadUrl`、`thumbnailUrl` 只接受绝对 HTTPS URL 和固定 host allowlist。
|
||||
- 文件名用于展示时必须由 UI 作为文本渲染,不得作为 HTML。
|
||||
|
||||
PDF 已有真实样本。TXT 是否沿用完全相同的 `cardType=12` 结构尚无真实证据;实现可以设计为通用文件合同,但 TXT 的验收必须等待真实样本。
|
||||
|
||||
## 8. `custom.type=10010` 不等于附件
|
||||
|
||||
本次还抓到以下 WebSocket 原始类型:
|
||||
|
||||
```text
|
||||
contentType=101
|
||||
custom.type=10010
|
||||
custom.data=<base64-json>
|
||||
```
|
||||
|
||||
但 Base64 解码结果为:
|
||||
|
||||
```json
|
||||
{
|
||||
"cardType": 2000,
|
||||
"params": {
|
||||
"country": "<value>",
|
||||
"ctime": "<value>",
|
||||
"ids": "<value>",
|
||||
"type": "<value>"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
SDK 将其归一化为:
|
||||
|
||||
```text
|
||||
msgType=10010
|
||||
subType=2000
|
||||
```
|
||||
|
||||
这是一类业务卡片,不是附件。若代码只写:
|
||||
|
||||
```ts
|
||||
if (content.custom.type === 10010) return file;
|
||||
```
|
||||
|
||||
就会错误地把商品、国家或其它业务卡片当成文件。
|
||||
|
||||
正确判定必须至少包含:
|
||||
|
||||
```text
|
||||
contentType=101
|
||||
custom.type=10010
|
||||
decoded.cardType=12
|
||||
decoded.params 通过文件 schema
|
||||
```
|
||||
|
||||
其它合法但未支持的 `cardType` 应返回受控的 `unsupported` 内容,不应丢弃整条消息,也不应保留完整 raw payload。
|
||||
|
||||
## 9. 当前代码为什么表现为“只有文本”
|
||||
|
||||
### 9.1 页面观察器
|
||||
|
||||
当前 `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts`:
|
||||
|
||||
```ts
|
||||
const contentRecord = isRecord(message.content) ? message.content : null;
|
||||
|
||||
if (contentRecord && isFiniteNumber(contentRecord.contentType)) {
|
||||
output.contentType = contentRecord.contentType;
|
||||
}
|
||||
|
||||
if (contentRecord) output.content = contentRecord;
|
||||
|
||||
const contentText = contentRecord?.text;
|
||||
if (isRecord(contentText) && typeof contentText.content === "string") {
|
||||
output.text = contentText.content;
|
||||
} else {
|
||||
output.text = null;
|
||||
}
|
||||
```
|
||||
|
||||
因此:
|
||||
|
||||
- 文本:得到 `contentType=1`、完整 raw content 和 `text=正文`。
|
||||
- 图片/文件:得到 `contentType=101`、完整 raw content 和 `text=null`。
|
||||
- 当前没有任何代码把 Base64 `custom.data` 变成 `image` 或 `file`。
|
||||
|
||||
### 9.2 Service Worker 与 IndexedDB
|
||||
|
||||
`pageMessageToObserved()` 会复制页面观察字段。IndexedDB 只检查:
|
||||
|
||||
```text
|
||||
content 是合法 JSON value
|
||||
contentType 是有限整数
|
||||
text 是 string | null | undefined
|
||||
```
|
||||
|
||||
所以图片和文件不会因为 `text=null` 被拒绝。它们以 opaque JSON 进入:
|
||||
|
||||
```text
|
||||
onetalk_messages
|
||||
onetalk_sync_candidates
|
||||
```
|
||||
|
||||
本次运行态的 `confirmed` 计数证明图片/附件 raw 记录确实通过了现有 ACK 链路。
|
||||
|
||||
### 9.3 共享协议与 Bright
|
||||
|
||||
当前 `OneTalkMessage` 定义:
|
||||
|
||||
```ts
|
||||
type OneTalkMessage = {
|
||||
messageId: string;
|
||||
conversationId: string;
|
||||
senderId: string;
|
||||
direction: "sent" | "received";
|
||||
sentAtMs: number;
|
||||
content: OneTalkJsonValue;
|
||||
contentType: number;
|
||||
text?: string | null;
|
||||
participantIds: string[];
|
||||
readStatus: number;
|
||||
messageStatus: number;
|
||||
unreadCount: number;
|
||||
};
|
||||
```
|
||||
|
||||
`content` 只有“可序列化 JSON”约束,没有 `text/image/file` 语义。
|
||||
|
||||
Bright 数据库当前保存:
|
||||
|
||||
```text
|
||||
content_type integer
|
||||
text text nullable
|
||||
content jsonb
|
||||
```
|
||||
|
||||
因此 Bright 能保存媒体 raw JSON,但不能告诉 Mind:
|
||||
|
||||
```text
|
||||
这是图片还是文件
|
||||
哪个 URL 用于预览
|
||||
哪个 URL 用于下载
|
||||
文件名和大小是什么
|
||||
未知 card 应如何展示
|
||||
```
|
||||
|
||||
### 9.4 当前展示边界
|
||||
|
||||
仓库内的 Bright harness 只执行:
|
||||
|
||||
```js
|
||||
JSON.stringify({ content: message.content }, null, 2);
|
||||
```
|
||||
|
||||
它没有:
|
||||
|
||||
```text
|
||||
<img>
|
||||
附件链接
|
||||
文件名
|
||||
文件大小
|
||||
类型图标
|
||||
unsupported fallback
|
||||
```
|
||||
|
||||
真实 Mind UI 如果只读取 `message.text`,媒体消息自然不可见。
|
||||
|
||||
## 10. 当前安全风险
|
||||
|
||||
### 10.1 字符串内部的敏感字段绕过清洗
|
||||
|
||||
Bright 当前的敏感键规则能删除普通对象中的:
|
||||
|
||||
```text
|
||||
token
|
||||
cookie
|
||||
csrf
|
||||
authorization
|
||||
secret
|
||||
password
|
||||
credential
|
||||
sid
|
||||
app-key
|
||||
```
|
||||
|
||||
但清洗器遇到字符串会立即原样返回:
|
||||
|
||||
```ts
|
||||
if (typeof value === "string" || typeof value === "boolean") return value;
|
||||
```
|
||||
|
||||
以下两种载荷因此不会被递归检查:
|
||||
|
||||
1. `text.extension.basicMessageInfo` 中的序列化 JSON。
|
||||
2. `custom.data` 中的 Base64 JSON。
|
||||
|
||||
真实 `basicMessageInfo` 解析后已确认含有 `chatToken` 键。当前实现把完整 raw content 交给 Bright,违反了数据库注释中“不得写入带认证信息的完整 envelope”的不变量。
|
||||
|
||||
### 10.2 推荐的安全边界
|
||||
|
||||
媒体支持不能靠“服务端收到 raw 后再尽量清洗”。应改为:
|
||||
|
||||
```text
|
||||
OneTalk raw message
|
||||
→ MAIN world 严格解码
|
||||
→ 显式构造白名单 OneTalkMessageContent
|
||||
→ 页面桥
|
||||
→ Service Worker exact decoder
|
||||
→ Bright exact decoder
|
||||
→ JSONB / message.created / HTTP history
|
||||
```
|
||||
|
||||
禁止跨边界的字段包括:
|
||||
|
||||
```text
|
||||
raw content object
|
||||
text.extension
|
||||
basicMessageInfo
|
||||
custom.data
|
||||
chatToken
|
||||
Cookie
|
||||
Authorization
|
||||
完整 OneTalk response/envelope
|
||||
原始 SDK row
|
||||
```
|
||||
|
||||
### 10.3 URL 风险
|
||||
|
||||
图片和文件样本中的 URL 是带查询参数的 HTTPS 操作地址,而不是直接媒体 URL。实现前还必须验证:
|
||||
|
||||
- 是否依赖 OneTalk 登录 Cookie。
|
||||
- 是否包含短期授权或一次性参数。
|
||||
- 是否可以从 Mind 页面所在 Origin 使用。
|
||||
- 是否会发生 302 跳转,以及跳转地址的生命周期。
|
||||
- 是否允许服务端代理;若允许,如何做授权和 SSRF 防护。
|
||||
- URL 是否适合长期持久化,还是只能保存媒体 ID 并在需要时换取临时地址。
|
||||
|
||||
在上述行为没有真实验证前,只能称其为 `sourceUrl`,不能承诺“Mind 可直接下载”。
|
||||
|
||||
## 11. 推荐的目标内容合同
|
||||
|
||||
建议让 `content` 成为消息展示内容的唯一事实源:
|
||||
|
||||
```ts
|
||||
type OneTalkNormalizedContent =
|
||||
| {
|
||||
kind: "text";
|
||||
text: string;
|
||||
}
|
||||
| {
|
||||
kind: "image";
|
||||
fileId: string;
|
||||
suffix: string;
|
||||
sizeBytes: number;
|
||||
width: number;
|
||||
height: number;
|
||||
isOriginal: boolean;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
}
|
||||
| {
|
||||
kind: "file";
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
extension: string;
|
||||
sizeBytes: number;
|
||||
md5?: string;
|
||||
sourceUrl: string;
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
| {
|
||||
kind: "unsupported";
|
||||
sourceContentType: number;
|
||||
sourceCustomType?: number;
|
||||
cardType?: number;
|
||||
};
|
||||
```
|
||||
|
||||
兼容期可以保留顶层 `message.text`,但它必须始终从:
|
||||
|
||||
```ts
|
||||
message.content.kind === "text" ? message.content.text : null;
|
||||
```
|
||||
|
||||
派生,不能成为第二个可独立写入的事实源。
|
||||
|
||||
现有 JSONB 列可以保存这个 union。只有在需要文件类型索引、全文搜索或独立媒体生命周期时,才需要新增数据库列或媒体表;第一阶段不应为了渲染图片/PDF创建第二套消息表。
|
||||
|
||||
## 12. 可以做到什么
|
||||
|
||||
### 12.1 当前已经做到
|
||||
|
||||
- 观察文本、图片和 custom card 的原始 WebSocket content。
|
||||
- 使用 raw `channelAccountId + conversationId + messageId` 保持消息幂等。
|
||||
- 将非文本消息写入 IndexedDB。
|
||||
- 将非文本消息作为 JSON observation 发送给 Bright。
|
||||
- Bright 可以 ACK 图片/附件 raw observation。
|
||||
- Bright JSONB 可以保存规范化后的 `text/image/file/unsupported` union,无需立即增加第二张消息表。
|
||||
|
||||
### 12.2 基于现有证据可以立即实现
|
||||
|
||||
- 文本严格白名单提取,删除全部 extension。
|
||||
- `custom.type=7` 图片 Base64 JSON 解码和字段校验。
|
||||
- `custom.type=10010 + cardType=12` 通用文件解码。
|
||||
- PDF 文件名、扩展名、大小、源 URL、缩略图元数据同步。
|
||||
- 未支持 card 的安全 fallback,不丢失消息身份和时间线位置。
|
||||
- Mind 侧按 `content.kind` 渲染文本、图片占位/预览和附件链接。
|
||||
- 在共享协议、HTTP history 和 `message.created` 中使用同一 typed content。
|
||||
- 添加敏感字段负向测试,证明 raw content、Base64 和序列化 JSON 不再跨边界。
|
||||
|
||||
### 12.3 当前还不能承诺
|
||||
|
||||
- TXT 附件与 PDF 完全同构:没有真实 TXT 样本。
|
||||
- 实时图片/文件 push 与历史响应完全同构:没有实时媒体 push 样本。
|
||||
- `sourceUrl` 能在 Mind 页面直接打开:未验证 Cookie、CORS、跳转和有效期。
|
||||
- 图片/附件可以永久下载:当前 URL 可能只是临时操作入口。
|
||||
- 支持音频、视频、语音、压缩包或所有 OneTalk custom card:没有真实样本和枚举。
|
||||
- checkpoint 已完整收敛:本次末态仍为 `uploading/succeeded`。
|
||||
|
||||
## 13. 推荐 PRD 要求
|
||||
|
||||
### R1. 单一内容合同
|
||||
|
||||
扩展、Bright 和 Mind 必须共用一个 `OneTalkNormalizedContent` 判别联合。页面 raw payload、服务端 JSONB 和 UI 本地模型不得分别维护不同的媒体判定逻辑。
|
||||
|
||||
### R2. 页面边界白名单解码
|
||||
|
||||
MAIN world 是 OneTalk raw content 的唯一解码所有者:
|
||||
|
||||
- 文本只取 `text.content`。
|
||||
- 图片只接受 `contentType=101/custom.type=7` 的有效 Base64 JSON。
|
||||
- 文件只接受 `contentType=101/custom.type=10010/cardType=12`。
|
||||
- 其它类型输出有限 `unsupported` 元数据。
|
||||
- raw content 和 custom data 不得进入页面桥。
|
||||
|
||||
### R3. 保留事实身份
|
||||
|
||||
媒体规范化不得改变现有幂等键:
|
||||
|
||||
```text
|
||||
channelAccountId + conversationId + raw messageId
|
||||
```
|
||||
|
||||
不得用 SDK 展示层 ID、时间戳、hash 或合成 ID 代替 raw message ID。
|
||||
|
||||
### R4. URL 与文件元数据校验
|
||||
|
||||
- URL 只允许 HTTPS 和固定 OneTalk host allowlist。
|
||||
- URL query/hash/userinfo 不进入日志或错误。
|
||||
- 文件大小、宽高和时间字段必须有上下限。
|
||||
- 文件扩展名规范化为小写有限字符集。
|
||||
- 文件名按纯文本处理并限制长度。
|
||||
- MD5 只能作为来源元数据,不能替代消息 ID 或安全签名。
|
||||
|
||||
### R5. 历史与实时一致
|
||||
|
||||
历史 `body.userMessageModels[].message.content` 和实时 `lastMessage.message.content` 必须走同一个 decoder。只有在真实 live 媒体样本证明 envelope 不同后,才允许增加窄化适配层。
|
||||
|
||||
### R6. 未支持类型行为
|
||||
|
||||
合法但未支持的 custom card:
|
||||
|
||||
- 不伪装成文本或附件。
|
||||
- 不保存 raw content。
|
||||
- 保留消息事实并使用 `kind=unsupported`。
|
||||
- 输出稳定的安全类型码或诊断,不输出 raw data。
|
||||
|
||||
非法 Base64、非法 JSON、字段类型错误、超限或非 HTTPS URL:
|
||||
|
||||
- 进入明确 anomaly 或受控 unsupported 策略。
|
||||
- 不得静默返回空文本。
|
||||
- 不得阻塞同批其它有效消息。
|
||||
|
||||
### R7. 跨层一致性
|
||||
|
||||
以下边界必须同时升级并由共享 decoder 约束:
|
||||
|
||||
```text
|
||||
MAIN observed message
|
||||
page bridge
|
||||
Service Worker storage/candidate
|
||||
message.observed
|
||||
Bright normalization
|
||||
PostgreSQL JSONB
|
||||
message.created
|
||||
HTTP history
|
||||
Mind UI model
|
||||
```
|
||||
|
||||
如果合同为不兼容变更,应提升 OneTalk 协议版本并让旧插件得到明确升级错误,不能在同一版本内让 `content` 同时表示 raw 与 normalized 两种含义。
|
||||
|
||||
### R8. 安全清除
|
||||
|
||||
任何通过网络、数据库、日志或诊断的消息都必须证明不包含:
|
||||
|
||||
```text
|
||||
chatToken
|
||||
basicMessageInfo
|
||||
custom.data
|
||||
Cookie
|
||||
Authorization
|
||||
accountIdEncrypt
|
||||
aliIdEncrypt
|
||||
原始 URL query 值
|
||||
完整 raw payload
|
||||
```
|
||||
|
||||
## 14. 推荐验收标准
|
||||
|
||||
- [ ] 真实文本历史消息归一化为 `{ kind: "text", text }`,并且 content 不含 extension。
|
||||
- [ ] 真实 JPEG 样本从 `custom.type=7` Base64 JSON 归一化为 image,尺寸、大小、后缀与真实载荷一致。
|
||||
- [ ] 真实 PDF 样本从 `custom.type=10010/cardType=12` 归一化为 file,文件名、扩展名、大小和 URL 元数据一致。
|
||||
- [ ] `custom.type=10010/cardType=2000` 不会被识别成文件。
|
||||
- [ ] 非法 Base64、非法 JSON、超限数据、非 HTTPS URL、缺字段和大小溢出均 fail closed,不影响同批其它消息。
|
||||
- [ ] page bridge、IndexedDB、Bright DB、HTTP history 和 `message.created` 中均只存在 normalized content。
|
||||
- [ ] 序列化最终 frame、数据库输入和诊断事件,断言不包含 `chatToken`、`basicMessageInfo`、`custom.data` 或加密身份字段。
|
||||
- [ ] Mind UI 能渲染文本、图片状态和 PDF 附件;URL 不可用时显示明确失败,不显示空白消息。
|
||||
- [ ] unknown custom card 显示“暂不支持的消息类型”,同时保留正确的消息时间线和幂等身份。
|
||||
- [ ] 获得真实 TXT 样本后补充同构性测试;未获得前不宣称 TXT 已验证。
|
||||
- [ ] 获得真实 live 图片和附件 push 后证明历史与实时共用同一 decoder。
|
||||
- [ ] 验证媒体 URL 在有 Cookie、无 Cookie、跨 Origin、过期和跳转场景的行为,再决定保存 URL、保存 ID 或增加受控代理。
|
||||
|
||||
## 15. 相关代码位置
|
||||
|
||||
| 层 | 文件 | 当前行为 |
|
||||
| -------------- | --------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| WebSocket 分流 | `apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts` | 识别 history/new envelope |
|
||||
| 页面消息提取 | `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts` | 复制 raw content,仅提取 text |
|
||||
| 历史只读 SDK | `apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts` | 调用 `fetchMessagesWithoutUpdateToRead` |
|
||||
| 页面到 SW | `apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts` | 原样复制观察字段 |
|
||||
| IndexedDB 校验 | `apps/chrome-extension/src/onetalk/service-worker/storage.ts` | 只检查 JSON 与基础字段类型 |
|
||||
| 共享合同 | `apps/onetalk-contract/src/model.ts` | `content` 为任意 JSON value |
|
||||
| 共享 decoder | `apps/onetalk-contract/src/decoder.ts` | 不理解 text/image/file 语义 |
|
||||
| Bright 清洗 | `apps/server/src/onetalk/service.ts` | 按键过滤对象,字符串原样通过 |
|
||||
| Bright 持久化 | `apps/server/src/database/schema/onetalk.ts` | `content jsonb`、`text nullable` |
|
||||
| 开发展示 | `apps/server/src/http/harness.ts` | 只展示 raw JSON |
|
||||
| 现有媒体覆盖 | `apps/chrome-extension/test/onetalk-websocket-tap.test.js` | 只有简化 `custom.type=10010` fixture |
|
||||
|
||||
## 16. 最终判断
|
||||
|
||||
OneTalk 当前真实载荷已经提供实现图片和文件同步所需的核心元数据,且 raw `custom.data` 可以在页面内稳定识别为 Base64 JSON。第一阶段不需要下载媒体文件,也不需要新建第二套消息表;可以在现有消息链上增加一个唯一的内容 decoder 和跨层 typed contract。
|
||||
|
||||
实现工作的核心不是“把 `contentType=101` 放行”,因为当前已经放行;真正需要完成的是:
|
||||
|
||||
```text
|
||||
opaque raw content
|
||||
→ 严格、安全、可测试的 text/image/file/unsupported 合同
|
||||
→ Bright 持久化与事件保持同一语义
|
||||
→ Mind 按 kind 展示
|
||||
```
|
||||
|
||||
同时必须先关闭 raw 字符串中嵌套凭证可能进入 Bright 的安全缺口,否则新增媒体支持会进一步扩大敏感 payload 的存储范围。
|
||||
Reference in New Issue
Block a user