docs(onetalk): document media message formats and sync plan

This commit is contained in:
YBF
2026-09-04 00:59:05 +08:00
parent a6e6769b06
commit 94aaad93e6
13 changed files with 3209 additions and 0 deletions
@@ -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 v2normalized 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 decoderServer 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 CHECKJSON 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 bridgeraw 和未知字段拒绝,安全 diagnostics 通过。
5. IndexedDB v5:清理四个同步 store,保留 profile,随后 full sync。
6. Server:三分支入库/读取、duplicate、commit→ACK→publish 顺序。
7. PostgreSQL:新 migration、CHECK、旧列删除、精确数据重置。
8. Mind parityhistory message 与 `message.created` 深度等价。
9. OSS:生产配置 fail fastfake 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 2MAIN-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 3Page 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 4Server 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 configregion、endpoint、bucket、versioned object key、target extension version、credentials/optional STS token。
4. production 配置缺失/非法时 fail fastdev/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 6OneTalk 页面升级横幅
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 7Mind-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 BChrome MAIN decoder、page bridge、IDB、upgrade banner。
- Workstream CServer service/repository/migration/OSS signer。
- Workstream DMind-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 上传到 ServerServer 持久化并透传给 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 入口,但真实运行态验收状态只能标记为待样本补证。
@@ -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`:协议固定 v2message/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 才 publishduplicate 不重复发布。
- `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 和 fragmenthost 必须精确等于配置的 OSS endpoint。
- 插件只把完整 URL保存在 Service Worker 内存与目标 tab 的升级横幅中;不得写入 `chrome.storage`、IndexedDB 或 console。
- URL 过期后禁用按钮,用户重新加载 OneTalk 页面并重新连接以获得新地址。
@@ -0,0 +1,101 @@
# OneTalk 媒体运行态合同证据
## 1. 环境
- ChromiumChrome 154CDP 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 ProtocolCDP`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 消息和三态发送结果 |
U01U04、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 代码。
- 不执行图片/文件发送、上传或二进制下载。
- 不以静态代码检查代替真实运行态结论。
+432
View File
@@ -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。
+904
View File
@@ -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_messages135
contentType=1125
contentType=101/custom.type=71
contentType=101/custom.type=100109
onetalk_sync_candidates135
confirmed135
anomaly0
onetalk_sync_checkpoints2
phase=uploading2
syncResult=succeeded2
```
注意:
- `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.hasMore0 | 1
body.nextCursornumber | 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 hasMoreboolean
WebSocket hasMore0 | 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
msgType101
type1
subType1
viewType0
contentstring
originalData keystext
```
### 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
msgType102
type1
subType60
viewType0
```
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
msgType10010
type1
subType61
viewType0
originalData.cardType12
originalData.params.extensionTypepdf
```
### 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 的存储范围。