feat(onetalk): sync media messages and attachments

This commit is contained in:
YBF
2026-09-04 01:10:11 +08:00
parent d674895640
commit f6d5d7620a
77 changed files with 4049 additions and 2243 deletions
@@ -110,7 +110,7 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
- Contract: exact profile/frame keys, protocol version, direction/scope, direct discovery type, sensitive/unknown key rejection, empty/over-limit batches and `256 KiB` byte limit.
- Observer: page snapshot/update, group exclusion, logged-in identity, logout/account switch, duplicate fingerprints, CRM customer matching and avatar URL validation.
- Ledger: version-5 store creation and migration, account/conversation key, durable-first ordering, abort retention, latest pending replacement, uploaded HWM, stale different-fingerprint skip, exact ACK/CAS, future-skew discard, reconnect and restart recovery.
- Ledger: v6 upgrade clears every old OneTalk store before recreating current profile state; account/conversation key, durable-first ordering, abort retention, latest pending replacement, uploaded HWM, stale different-fingerprint skip, exact ACK/CAS, future-skew discard, reconnect and restart recovery. The upgrade must preserve configuration/deviceId and must not rekey/retry a v5 pending ledger.
- Service Worker: existing Bright binding/read/sync authorization, profile request mapping, ACK count, future error mapping and stale callback/page identity fences; direct discovery always carries `conversationType: "direct"`.
- Direct typecheck, contract/extension focused and full tests, format check and `git diff --check` are required. Real Chromium, Bright PostgreSQL and production Mind integration are separate external checks.
@@ -61,7 +61,9 @@ rejected
### Profile ledger (independent state machine)
联系人资料不使用消息 candidate/checkpoint/anomaly store。IndexedDB version 5 的 `onetalk_contact_profiles` 业务键为 `channelAccountId + conversationId`;记录包含 key、账号、conversationId、资料字段中的 aliId、lastUploadedFingerprint、uploaded/rejected observed high-water mark、updatedAt、lastUploadedAt 和最新 pending 清洗 profile。资料上传到 Bright persistence,不调用 Mind profile HTTP
联系人资料不使用消息 candidate/checkpoint/anomaly store。`ONE_TALK_SYNC_DATABASE_VERSION=6`:任何 `oldVersion < 6` 的升级 transaction 必须清空五个 OneTalk store(消息、candidate、checkpoint、anomaly、`onetalk_contact_profiles`),不重键或重试旧 v5 ledger。Chrome 配置、deviceId、binding 与其它渠道存储不属于该删除范围;升级后必须重新采集 profile 并从 clean state 执行 full sync
清空后的 `onetalk_contact_profiles` 业务键为 `channelAccountId + conversationId`;记录包含 key、账号、conversationId、资料字段中的 aliId、lastUploadedFingerprint、uploaded/rejected observed high-water mark、updatedAt、lastUploadedAt 和最新 pending 清洗 profile。资料上传到 Bright persistence,不调用 Mind profile HTTP。
Observation 先写最新 pending,再由现有 Bright WebSocket 发送 contact.profile.observed。同 fingerprint 且无 pending 时只推进更高 observed high-water mark;任何不高于 uploaded/rejected high-water mark 的不同 fingerprint 也跳过。断线、Service Worker 重启或新页面连接只从 pending 重建发送。收到 contact.profile.ack 后,必须等待 readwrite transaction oncomplete,且只确认仍匹配的 fingerprint 和 observedAtMs;迟到旧 ACK 不得删除新 pending。收到 `ws.error` `profile_observed_at_future` 时只丢弃该 request 的精确 pending,避免无限重试。
@@ -167,7 +169,7 @@ Service Worker 重启后必须从 IndexedDB 恢复:
- 断线恢复会重新发现会话并恢复未确认事实和 completion 声明。
- \`delivery_unknown\` 不创建发送任务、不自动重发;迟到消息继续进入普通 observation。
- 页面同步路由、Bright ACK 和 IndexedDB 事务顺序在重启/断线下保持一致。
- Profile migration 保留旧四个 stores 并把既有 profile ledger 升级为 version 5 的 account/conversation keycommit/abort/error、重连、旧 ACK、chunk/latest-wins、HWM/future-skew discard 和 snapshot non-blocking 都必须有回归
- v5 或更早升级到 v6 必须断言五个 OneTalk store 均为空、没有 profile 重键/重试分支,且下一次 bootstrap 重新采集 profile 并执行 full sync;配置、deviceId 与其它渠道数据保持不变
## 7. Wrong vs Correct
@@ -51,7 +51,7 @@ type OneTalkPageHello = {
\`\`\`ts
const ONE_TALK_PAGE_BRIDGE_SOURCE = "trade-message-center.onetalk.page-bridge";
const ONE_TALK_PAGE_BRIDGE_VERSION = 1;
const ONE_TALK_PAGE_BRIDGE_VERSION = 2;
const ONE_TALK_PAGE_PORT_NAME = "trade-message-center.onetalk.page";
\`\`\`
@@ -60,6 +60,7 @@ const ONE_TALK_PAGE_PORT_NAME = "trade-message-center.onetalk.page";
### Ownership
- MAIN world 只拥有 OneTalk SDK 访问、页面事实采集和页面命令执行;不得持有 Bright WebSocket、认证凭证或扩展 IndexedDB。
- OneTalk raw `contentType``custom.data`、SDK envelope 和认证字段只能在 MAIN 内短暂存在;MAIN 唯一 decoder 必须先生成 shared `OneTalkMessageContent``text | image | file` v1 联合,才允许跨 bridge。
- ISOLATED Content Script 只拥有页面桥和 \`runtime.Port\`;不得解释业务 payload、保存同步状态或选择备用页面。
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、命令路由、上传编排和 IndexedDB 访问。
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
@@ -101,6 +102,8 @@ ISOLATED 只做以下动作:
Port 断开或发送失败时,丢弃当前内存消息,不建立页面本地队列、重试或持久化。
bridge v2 只允许 exact-shape normalized message 和聚合后的安全诊断。`unsupported_skipped` 或媒体 anomaly 只含稳定 code/类型/计数;不得携带消息 ID、正文、URL 或 raw payload。ISOLATED 与 Service Worker 不得重新解析 Base64、`custom.data` 或推测媒体链接。
Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN 与 ISOLATED 产物必须按当前构建约束生成自包含入口,不能依赖 Manifest 未声明的共享 chunk。
### Page identity
@@ -169,6 +172,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
- \`onetalk.send\` 同账号零页面返回 \`waiting_for_page\`,多页面返回 \`ambiguous_page_route\`,均不广播。
- 页面 post 失败、Port 断开和身份变化都返回带 reason 的 unknown。
- 页面桥 build 产物为自包含入口,Manifest 路径、world 和 Port 名称一致。
- raw `contentType``custom.data`、顶层 `text` 或未知 content version 均不能通过 bridgehistory/live 必须复用 MAIN 的同一 decoder。
## 7. Wrong vs Correct
@@ -17,7 +17,7 @@ OneTalk 插件同时需要以下能力时,遵循本总览和对应子规范:
| --- | --- |
| [OneTalk 页面桥、Port 与命令路由](./page-bridge.md) | MAIN/ISOLATED/SW 页面桥、Port 注册、页面身份和 command 路由 |
| [OneTalk 耐久同步与连接生命周期](./durable-sync.md) | IndexedDB、full/incremental/live、ACK、checkpoint、重启恢复和连接生命周期 |
| [OneTalk 联系人资料 Bright 持久化](./contact-profile-sync.md) | profile 白名单、Bright profile frame、version-5 独立 ledger、ACK/HWM/future-skew 和账号/epoch 隔离 |
| [OneTalk 联系人资料 Bright 持久化](./contact-profile-sync.md) | profile 白名单、Bright profile frame、v6 清空后重采集 ledger、ACK/HWM/future-skew 和账号/epoch 隔离 |
| [OneTalk 扩展安装实例设备身份](./device-identity.md) | deviceId 生成、迁移、独立存储、配置清除和生命周期 |
| [OneTalk Service Worker 状态与诊断](./runtime-diagnostics.md) | getSnapshot、错误投影、敏感信息脱敏和 development 构建 |
| [OneTalk PWA 出站发送 SOP](./send-sop.md) | sendUIMessages 输入、SDK-only 发送和 WebSocket 旁路事实确认 |
@@ -36,7 +36,7 @@ OneTalk MAIN world
-> Bright WebSocket upload
-> per-message ACK
联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker version-5 profile ledger → existing Bright plugin WebSocket `contact.profile.observed` → Bright guarded profile transaction → `contact.profile.ack`
联系人资料事实使用独立路径:OneTalk MAIN snapshot/syncData → safe profile envelope + page identity → Service Worker v6 clean-state profile ledger → existing Bright plugin WebSocket `contact.profile.observed` → Bright guarded profile transaction → `contact.profile.ack`
\`\`\`
服务端命令:
@@ -60,6 +60,7 @@ Bright WebSocket
- ISOLATED Content Script 只拥有页面桥和 runtime.Port;不得解释业务 payload、保存同步状态或选择备用页面。
- Service Worker 拥有 Bright 插件 WebSocket、页面连接注册、账号隔离、command 路由、上传编排、IndexedDB 和状态投影。
- 共享页面消息契约由 page bridge model/decoder 唯一拥有;Bright wire frame 契约由 onetalk-contract 唯一拥有。
- `protocolVersion=3``content.version=1` 独立演进;跨 MAIN 的消息仅是 shared normalized `text | image | file` content。raw `custom.data`、顶层 `text/contentType` 不能到达 ISOLATED、Service Worker、IndexedDB 或 Bright。
- Bright 是 OneTalk 消息事实的服务端写入口;TradeMind 不直接写 Bright 消息事实表。
- Bright 是联系人资料当前事实源;Mind 只提供页面/插件授权上下文,不接收 profile delivery,也不作为 Bright read model 的 profile projection owner。
- 每个跨层业务概念必须只有一个 owner:页面 command 结果先在页面边界形成,发送三态先在 contract/adapter 边界收窄,服务端事实只在 server ingest 中提交。
@@ -84,7 +85,7 @@ Bright WebSocket
- Bright 连接断开或发送结果丢失不得自动重发,不创建隐式发送任务。
- `authorization_unavailable` 表示授权依赖暂时不可用,只关闭当前 Bright socket 并沿既有连接退避自动重连;只有凭证、授权版本、binding、scope 或协议版本等确定性错误才阻断自动重连并进入 unauthorized。
- Service Worker 重启从 IndexedDB 恢复 checkpoint、候选和模式,不信任旧内存 cursor。
- Profile 重启/重连从独立 version-5 ledger 恢复 pendingACK 只在当前 `[channelAccountId, conversationId, fingerprint, observedAtMs]` 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。
- 对既有 v5 或更早数据库先执行 v6 全量 OneTalk state 清空,再重新采集 profile/full sync;之后的重启/重连才从当前 ledger 恢复 pendingACK 只在当前 `[channelAccountId, conversationId, fingerprint, observedAtMs]` 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。
### Send and protocol boundaries
@@ -7,12 +7,12 @@
当前已建立 OneTalk Bright 事实存储 schema,定义位于
[`apps/server/src/database/schema/onetalk.ts`](../../../../apps/server/src/database/schema/onetalk.ts)
- `onetalk_message`:页面事实消息。`channel_account_id + conversation_id + message_id` 复合主键负责幂等;收件和确认发件通过 `direction` 区分。`content` 只承载已规范化的消息内容,不保存包含认证信息的完整 OneTalk envelope。
- `onetalk_message`:页面事实消息。`channel_account_id + conversation_id + message_id` 复合主键负责幂等;收件和确认发件通过 `direction` 区分。`content` 是唯一内容事实,只承载 shared contract 的 versioned `text | image | file` JSON,不保存包含认证信息的完整 OneTalk envelope。
- `onetalk_conversation`:插件发现的技术会话和共享同步锚点。`channel_account_id + conversation_id` 复合主键,不按 binding 或设备复制;`conversation_kind` 只接受显式 `direct`,未知历史会话保持 `null``sync_phase``sync_result``latest_message_id``history_complete` 表达同步进度及锚点状态,并允许零消息会话。
- `onetalk_contact_profile`Bright 当前联系人资料事实。`channel_account_id + conversation_id` 复合主键,不建立到技术会话表的外键;资料字段允许显式 `null`,只有严格较新的 `observed_at_ms` 才能覆盖整行。
- `onetalk_message_anomaly`:缺字段、协议和同步异常的独立诊断事实。`fingerprint` 仅用于诊断合并;`payload` 必须由写入边界清洗,不能被消息读取、发送或锚点流程消费。
生成的初始迁移为 `apps/server/drizzle/0000_rapid_winter_soldier.sql`,其中显式维护 PostgreSQL 表/字段 `COMMENT ON` 备注(Drizzle 当前版本不会从 TypeScript 注释自动生成数据库备注)。未确认发送不进入任何一张表,普通消息和联系人资料也不提供物理删除路径;profile 当前行由读取服务实时 join,不复制进 conversation 或 message。
生成的初始迁移为 `apps/server/drizzle/0000_rapid_winter_soldier.sql`,其中显式维护 PostgreSQL 表/字段 `COMMENT ON` 备注(Drizzle 当前版本不会从 TypeScript 注释自动生成数据库备注)。未确认发送不进入任何一张表,普通运行路径不提供物理删除;profile 当前行由读取服务实时 join,不复制进 conversation 或 message。媒体切换 migration `0005_young_squadron_supreme` 是一次性开发数据重置:仅 `DELETE` 本仓库拥有的 OneTalk message/anomaly/profile/conversation 事实,再删除 `text/content_type` 并为 `content` 加 v1 kind CHECK;不触及授权、binding 或其它渠道。
## Scenario: Schema 注释与 PostgreSQL 备注
@@ -208,6 +208,7 @@ repository.updateSyncState(context, update, conversationId, conversationKind) ->
- 重复消息返回 `duplicate`,不得覆盖首次事实或再次触发外部事件;允许只更新 `last_observed_at`
- 跨 workspace 收到相同 `channel_account_id + conversation_id + message_id` 时,必须沿用同一条已存在事实:返回 `duplicate`,保留首次写入的 `workspace_id``mind_user_id``binding``device_id`,不得因后续 workspace 改写来源上下文。
- 消息读取按 `channel_account_id + conversation_id` 读取共享事实;workspace 隔离由 Mind 授权 scope 负责,不能把 `workspace_id` 加入消息事实主键或作为第二套消息副本维度。
- `content jsonb` 必须是对象,且 `version=1``kind in (text,image,file)`;应用边界再用 shared exact decoder 验证完整字段。不得保留顶层 `text``content_type`、raw content 或平行投影列。
- `content` 或文本相同本身不构成重复;只要 `message_id``conversation_id` 不同,就按新的 OneTalk 事实入库。
- `discoverConversation` 只按账号/会话幂等 upsert,不清空已有消息计数、同步结果或锚点。
- anomaly 以 `fingerprint` 唯一合并并递增 `occurrence_count`;payload 必须是领域层清洗后的 JSON。
@@ -236,9 +237,9 @@ repository.updateSyncState(context, update, conversationId, conversationKind) ->
### 6. Tests Required
- Domain:断言未知会话 reject、缺字段 anomaly、fingerprint 合并、敏感键清洗、四类同步结果和 latest ID 所属校验。
- Domain:断言未知会话 reject、缺字段/未知 content anomaly、fingerprint 合并、metadata-only anomaly、四类同步结果和 latest ID 所属校验。
- WebSocket:断言逐条 ACK、accepted/duplicate/anomaly/rejected 分流、plugin-only 写边界、数据库错误和精确 Mind 发布。
- PostgreSQL:使用显式 `TEST_DATABASE_URL` 执行真实 migration;断言同一复合键只有一行、消息计数为 1、首次 observation type 和来源 workspace 上下文保留,并在跨 workspace 重复上报后仍只有一行;测试账号结束后清理。
- PostgreSQL:使用显式 `TEST_DATABASE_URL` 执行真实 migration;断言 `0005` 只清空 OneTalk owned facts、旧列被删除、v1 CHECK 生效,随后 text/image/file 能 round-trip同一复合键只有一行、消息计数为 1、首次 observation type 和来源 workspace 上下文保留,并在跨 workspace 重复上报后仍只有一行;测试账号结束后清理。
- Static`db:check`、无 legacy outbox/dispatch 引用、`database commit -> ACK -> publish` 数据流检查。
### 7. Wrong vs Correct
@@ -254,10 +255,14 @@ sendAck({ status: "accepted" });
#### Correct
```ts
const result = await service.observeMessage(context, source, rawMessage);
const result = await service.observeMessage(context, source, normalizedMessage);
if (result.status === "accepted") {
sendAck(result);
await registry.publishMessageCreated({ message: result.message, requestId, scope });
await registry.publishMessageCreated({
message: toOneTalkCenterMessage(result.message),
requestId,
scope,
});
}
```
@@ -64,9 +64,9 @@
- contact.profile.observed 复用 plugin 的 `sync` + `read` 授权;资料写入 Bright 的 profile repository,不调用 Mind profile endpoint,也不读取/转发任何 profile HTTP response body。
- profile transaction 完成后,WebSocket handler 仍必须确认 binding、完整 Mind scope、authorizationVersion、read、canonical connection、policy epoch 和 commit guard;远程 revoke/version/read removal 不能产生迟到 ACK。future-skew 则在二次授权前返回稳定的 `profile_observed_at_future`,不写库、不 ACK。
## 8. Bright v2 operation fences
## 8. Bright v3 operation fences
- `OneTalkCutoverPolicy` 只拥有 Bright v2`enabled``paused` 和单调 `epoch``capture()`/`isCurrent(epoch)` 必须在每个异步副作用边界前后使用。`pause()` 使旧 epoch 失效并通知 registry 关闭既有 WebSocket,关闭语义为 `1013/authorization_unavailable`,不得先发送 `ws.error`
- `OneTalkCutoverPolicy` 只拥有 Bright v3`enabled``paused` 和单调 `epoch``capture()`/`isCurrent(epoch)` 必须在每个异步副作用边界前后使用。`pause()` 使旧 epoch 失效并通知 registry 关闭既有 WebSocket,关闭语义为 `1013/authorization_unavailable`,不得先发送 `ws.error`
- registry 是 `SendAttempt` 的唯一 owner。`sendRequestId` 在任何 Mind/plugin authorization `await` 前同步预占,状态只能按 `reserved -> authorizing -> dispatched -> awaiting_confirmation -> confirming -> terminal` 推进;wire send 前失败是 `rejected_before_send`wire send 后只能是 `confirmed_sent``delivery_unknown`
- confirmation 必须先同步 claim 并清理 timer;只有提交 confirmation 的 plugin 做一次 binding authorizationterminal 后的重复/迟到 confirmation 是 no-op。pending、connection、generation、binding、授权版本、完整 Mind scope 和 policy epoch 必须由同一 registry-owned guard 绑定。
- 远程 plugin observation、conversation discovery 和 sync completion 只能调用 required guarded repository portsguard 必须进入真实数据库 transaction,并覆盖查询、insert、update、duplicate read 和 callback 返回前的边界。缺 guard 时不得 fallback 为无保护写入。
@@ -24,7 +24,7 @@ startServer(): Promise<void>
- `createApp` never calls `listen`; only `entry.ts` may bind the port。
- Database resources are closed through the app `onClose` hook; URL and credentials never enter responses or logs。
- `createApp` composes one injected/default `OneTalkService`, `OneTalkProfileService` and `OneTalkReadService` over the same `database.db`; `AppDependencies` may inject these domain ports, the connection registry and publisher failure sink for tests or deployment adapters。
- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin `message.ack` → authorized Mind `message.created`
- WebSocket business frames cross through the service boundary; successful observation order is database commit → plugin `message.ack` → authorized Mind `message.created``message.created` 与 HTTP history 都必须从同一 normalized JSONB fact 投影 shared `OneTalkCenterMessage`,不得暴露顶层 `text/contentType` 或 raw payload。
- `contact.profile.observed` is a Bright persistence path: canonical binding/read/sync authorization → profile service → guarded Bright transaction → post-write fence → `contact.profile.ack`; it never calls Mind profile HTTP or the message service. `AppDependencies.profileService` is the test/deployment seam.
## 4. Validation & Error Matrix
@@ -70,7 +70,7 @@ app.get("/health", async () => ({ status: "ok" }));
The health boundary is stable and secret-free; database probing belongs in a later operational contract。
## Scenario: Bright v2 authorization and commit fences
## Scenario: Bright v3 authorization and commit fences
### 1. Scope / Trigger
@@ -90,7 +90,7 @@ repository.guardedUpdateSyncState(context, update, conversationId, guard): Promi
### 3. Contracts
- Bright v2 policy 只通过 `enabled/paused/epoch` 控制 admissionpause/resume 可以恢复 Bright v2,不依赖或保存 `firstBrightFactWritten`/`markBrightFactWritten`
- Bright v3 policy 只通过 `enabled/paused/epoch` 控制 admissionpause/resume 可以恢复 Bright v3,不依赖或保存 `firstBrightFactWritten`/`markBrightFactWritten`
- `sendRequestId` 的 reserve、phase、terminal transition、timeout、disconnect、pause 和 late confirmation 由 registry 单一 owner 管理;所有 wire send 后结果不可自动重试。
- 远程 observation/discovery/sync/confirmation 的 database side effect 必须带同一 canonical connection/generation/policy guard。guard 失效必须使事务回滚,不返回伪造的 accepted/duplicate。
@@ -235,7 +235,8 @@ GET /harness -> text/html (native browser page)
- 会话只读取 `conversation_kind = "direct"` 的显式 direct fact。列表/详情返回共享 `CenterConversation``name`/`avatarUrl` 为当前 profile row 的实时值,`participantIds` 固定为空数组,`unreadCount` 固定为 0,latest 字段来自已持久化 message fact。
- 列表 query 先 trim,按名称或 conversationId 做 Unicode-insensitive substring;列表 cursor 不透明且绑定账号、query、asOf、(latestMessageAtMs, conversationId) keyset。当前 profile left join 是实时资料例外。
- 历史 cursor 不透明且独立绑定账号、会话、from/to 半开窗口、asOf 和 `(sentAtMs, messageId)` keyset;时间窗为 `from <= sentAtMs < to`。summary purpose `communication_summary_read` 必须同时提供两端时间。
- `/harness` 只通过同源 Bright HTTP/WS 访问数据;浏览器对 list/detail/history、scope、page、语义消息和稳定错误做运行时形状校验,展示原始 ID,渲染文本前必须转义,消息按 scope/account/conversation/messageId 去重。列表和历史都只回传服务端 opaque cursor
- HTTP history 与 `message.created` 都只返回 shared `OneTalkCenterMessage`:语义 `readStatus` 加同一 `content.version=1``text | image | file` union。read projection 不得解 Base64、`custom.data``contentType`、文件名或 URL fallback
- `/harness` 只通过同源 Bright HTTP/WS 访问数据;浏览器对 list/detail/history、scope、page、语义消息和稳定错误做运行时形状校验,展示原始 ID,按 scope/account/conversation/messageId 去重。它按 `content.kind` 转义渲染文本、以真实 `<img>` 加载图片预览并显示加载失败、为文件保留元数据且只在 URL 存在时提供带 `noopener noreferrer` 的用户触发链接;不解 raw 字段、不自动下载、不增加媒体发送。
- 插件 `plugin.status``sync.status``message.created` 只发送给当前仍通过二次 read 授权的精确 Mind scope;消息必须遵循数据库提交 → plugin ACK → Mind publish。HTTP CORS 只允许精确 Origin 和 `Content-Type`/`X-Mind-Purpose`
### 4. Validation & Error Matrix
@@ -262,9 +263,9 @@ GET /harness -> text/html (native browser page)
### 6. Tests Required
- HTTP:列表、详情、历史首/后续页、direct filter、profile realtime join、query、独立 cursor/asOf、半开窗口、summary gate、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应。
- WebSocketMind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序以及断线后的连接清理。
- Harness`GET /harness` 的 HTML 标记、Bright HTTP/WS 路径、summary header、offline send gate、list/history query paging、异步代际 fence、active-scope guard、运行时校验、转义和去重关键字段。
- HTTP:列表、详情、历史首/后续页、direct filter、profile realtime join、query、独立 cursor/asOf、半开窗口、summary gate、scope/CORS 校验、授权失败、未知会话、offline 状态、非法 limit/cursor/time range、数据库失败和无秘密响应text/image/file 必须只含 normalized content
- WebSocketMind hello/accepted、plugin online/offline、sync status、精确 scope、二次授权、提交后 ACK/publish 顺序、history/live 对同一事实的公开投影等价,以及断线后的连接清理。
- Harness`GET /harness` 的 HTML 标记、Bright HTTP/WS 路径、summary header、offline send gate、list/history query paging、异步代际 fence、active-scope guard、runtime shape validation、文本转义、image load error、conditional file links 和去重关键字段。
- PostgreSQL:复合索引上的 direct-only keyset 分页跨页不丢不重,cursor 与 anchor 独立,profile left join、asOf 和真实 migration 后读取仍按账号/会话隔离。
### 7. Wrong vs Correct
@@ -5,8 +5,8 @@
{"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/error-handling.md", "reason": "复核 v2 hard-reject、异常边界和脱敏"}
{"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 期限、授权和日志边界"}
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/bright-profile-integration.md", "reason": "复核 profile/direct fact 全清边界、v6 全量重置和 HTTP/WS 统一投影"}
@@ -11,7 +11,7 @@
3. PostgreSQL `content JSONB` 是唯一持久化内容事实源。
4. HTTP history 和 WS `message.created` 返回同一个数据库 message。
5. 不支持的卡片和损坏媒体不伪装成其它类型,也不推进伪造 anchor。
6. 媒体 URL 和升级 ZIP URL 都按 bearer-like 临时凭证处理。
6. 媒体 URL 按 bearer-like 临时凭证处理;不记录或伪造 URL
## 2. 总体数据流
@@ -25,7 +25,7 @@ OneTalk history / live message
├─ unsupported card → safe skipped counter
└─ malformed media → safe anomaly
→ page bridge v2normalized message + safe batch diagnostics
→ Service Worker IndexedDB v5
→ Service Worker IndexedDB v6
→ protocol v3 message.observed
→ Server shared decoder
→ PostgreSQL content JSONB
@@ -42,8 +42,8 @@ OneTalk history / live message
| --- | --- | --- |
| 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 |
| MAIN ↔ ISOLATED ↔ SW page bridge | `2` | raw content 不得跨桥,增加安全 batch diagnostics |
| 扩展 IndexedDB | `6` | 清空所有未上线的 OneTalk message/candidate/checkpoint/anomaly/profile 状态 |
协议版本与内容版本独立:传输 envelope 的不兼容变化提升 `protocolVersion`;仅 content union 的未来变化提升 `content.version`
@@ -102,6 +102,9 @@ export type OneTalkMessageContent =
- `version` 必须严格为 `1``kind` 必须匹配分支。
- ID、文件名和扩展名有长度上限;字符串 trim 后不得为空。
- `sizeBytes`、尺寸必须为非负安全整数并设置领域上限。
- 本 task 的领域上限固定为 `sizeBytes <= 10 * 1024 ** 3`10 GiB),图片
`width`/`height <= 65_535`;该范围覆盖当前真实样本和通用附件,同时拒绝
无界媒体元数据。越过上限必须 fail closed。
- `downloadState="available"` 当且仅当 `downloadUrl !== null``not_provided` 当且仅当 URL 为 `null`
- URL 为 `null` 表示 payload 未提供当前可用动作;空字符串不得进入 normalized contract。
- `md5` 缺失或空值统一为 `null`
@@ -123,7 +126,26 @@ export type OneTalkMessage = {
};
```
删除顶层 `text``contentType``OneTalkObservedMessage`发送确认消息history 和实时事件全部引用同一个类型
删除顶层 `text``contentType``OneTalkObservedMessage`发送确认消息引用该内部类型;history 和实时事件使用下节定义的公开读模型,但两者只共享这份 normalized content
### 4.3 Mind-facing message read model
`132a376` 已引入 `CenterMessage`,以隔离内部 OneTalk 数字状态和公开读取响应;该边界保留,但其类型所有权移入 `apps/onetalk-contract` 并改名为 `OneTalkCenterMessage`。它的 `content` 直接是 `OneTalkMessageContent`,不再以 `contentType: "text" | "img" | "attachment" | "unknown"` 表示媒体。
```ts
export type OneTalkCenterMessage = {
messageId: string;
conversationId: string;
senderId: string;
participantIds: string[];
direction: OneTalkDirection;
sentAtMs: number;
readStatus: "read" | "unread";
content: OneTalkMessageContent;
};
```
`OneTalkMessage` 是插件入站与持久化域类型;`OneTalkCenterMessage` 是唯一的 Mind HTTP/WS 读取类型。两者的 content 是同一份已验证 JSONB 事实,差别只在外层可公开字段及 `readStatus` 的语义投影,不能产生第二个内容事实源。
## 5. MAIN-world raw content decoder
@@ -159,6 +181,11 @@ type OneTalkRawContentDecodeResult =
Raw content、消息 ID、正文和 URL 不进入 skip/anomaly 输出。
若 raw message 的 identity、参与者或方向不完整/不一致,MAIN 不跨桥传出
该消息,但必须在同一聚合诊断中记录安全的 `invalid_observation` 类型计数;
不得静默丢弃,也不得阻塞同批有效消息。该诊断同样不得携带消息 ID、正文、
URL 或 raw payload。
### 5.3 文本
判定 `contentType=1` 且存在 `text.content: string`
@@ -293,7 +320,7 @@ type OneTalkParsedBatch = {
- Anchor 只取已获得 `accepted|duplicate` ACK 的最新有效消息。
- 若最新原始消息被跳过,anchor 保持在最近有效消息;后续增量可能再次扫描该项,诊断去重但不得合成 anchor。
## 7. Page bridge v2 与扩展 IndexedDB v5
## 7. Page bridge v2 与扩展 IndexedDB v6
### 7.1 Page bridge
@@ -304,13 +331,14 @@ type OneTalkParsedBatch = {
### 7.2 IndexedDB migration
- `ONE_TALK_SYNC_DATABASE_VERSION: 45`
- `oldVersion < 5` 时,在 versionchange transaction 中清空:
- `ONE_TALK_SYNC_DATABASE_VERSION: 56`
- 当前尚未上线;`oldVersion < 6` 时,在同一个 versionchange transaction 中清空全部 OneTalk store
- `onetalk_messages`
- `onetalk_sync_candidates`
- `onetalk_sync_checkpoints`
- `onetalk_sync_anomalies`
- 保留 `onetalk_contact_profiles`
- `onetalk_contact_profiles`
- 不保留 `<5` profile ledger 重键或任何旧 profile pending/ACK/rejected recordv3 首次页面观察重新收集 profile。
- 扩展配置和 deviceId 位于 `chrome.storage.local`,不参与清理。
- 空 checkpoint 使 v3 首次运行自然进入 full sync;不得额外维护迁移完成的第二状态。
@@ -329,23 +357,23 @@ type OneTalkParsedBatch = {
- `toMessage` 原样返回 DB content,不创建第二次媒体投影。
- 复合幂等键、时间索引、sender 索引和 duplicate 行为保持不变。
### 8.3 新 PostgreSQL migration
### 8.3 已合入 profile/read model 的整合
- 当前 migration 基线是 `0003_onetalk_contact_profile_facts``0004_onetalk_conversation_direct_fact`;新增媒体 migration 必须排在其后,Drizzle journal/snapshot 从当前 HEAD 生成。
- 当前未上线,`onetalk_contact_profile``onetalk_conversation.conversation_kind` 均为可丢弃开发事实:migration 删除它们连同 message/anomalyv3 profile 观察与 direct discovery 重新建立读取模型。跨系统授权/binding 与其它渠道数据不属于本 migration。
- HTTP `read-model.ts``OneTalkReadMessageRow` 改为已验证的 normalized content`read-projection.ts` 删除 `Buffer`、Base64/UTF-8/JSON、`custom.type` 和 URL fallback 逻辑,只执行 `readStatus` 语义化和 `OneTalkCenterMessage` 组装。
- WS `message.created` contract、registry 和 handler 发布同一个 `OneTalkCenterMessage`。accepted publish 仍发生在 DB commit 和 ACK 成功之后;duplicate 仍不发布。
### 8.4 新 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 负责。
1. `DELETE FROM onetalk_message``onetalk_message_anomaly`
2. `DELETE FROM onetalk_contact_profile``onetalk_conversation`,包括所有 direct fact 与同步状态
3. `onetalk_message` 删除 `text``content_type` 列。
4.`content` 增加轻量 DB CHECKJSON object、`version=1``kind IN ('text','image','file')`。完整 exact-shape 仍由共享 decoder 负责。
不得删除 conversation identity、授权/binding、联系人 profile 或其它渠道数据。迁移只在部署时执行,本任务测试不得对用户数据库直接运行破坏性命令。
删除范围仅限当前仓库拥有的 OneTalk 表;跨系统授权/binding 和其它渠道数据不属于 migration。迁移只在部署时执行,本任务测试不得对用户数据库直接运行破坏性命令。
## 9. Mind-facing history/event
@@ -357,7 +385,7 @@ type OneTalkParsedBatch = {
{
scope: OneTalkMindScope;
conversationId: string;
messages: OneTalkMessage[];
messages: OneTalkCenterMessage[];
page: { hasMore: boolean; nextCursor: string | null };
}
```
@@ -371,11 +399,11 @@ type OneTalkParsedBatch = {
type: "message.created";
requestId: string;
scope: OneTalkMindScope;
payload: { message: OneTalkMessage };
payload: { message: OneTalkCenterMessage };
}
```
同一消息的 `history.messages[i]` 必须与 `message.created.payload.message` 深度等价。两者外层 envelope 不要求相同。
同一消息的 `history.messages[i]` 必须与 `message.created.payload.message` 深度等价。两者外层 envelope 不要求相同。全量清理后,profile list/detail 在 v3 profile 观察与 direct discovery 重建事实前可为空;重建后的 join、direct filter 与 cursor 语义不变。
## 10. `/harness` 调试展示
@@ -389,102 +417,14 @@ type OneTalkParsedBatch = {
- 保留经过 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. 日志与安全
## 11. 日志与安全
- 删除 raw OneTalk WebSocket frame/完整 parsed message console 输出。
- 扩展和 Server diagnostics 只允许稳定事件名、错误码、方向、frame type、计数和布尔状态。
- 禁止记录 raw `custom.data`、正文、完整媒体 URL、OSS URL、query values、binding、Cookie、token、文件 ID或消息 ID。
- 禁止记录 raw `custom.data`、正文、完整媒体 URL、query values、binding、Cookie、token、文件 ID或消息 ID。
- 测试增加字符串与 Base64 嵌套敏感键的负向 fixture,证明 MAIN 出口后不存在这些字段。
## 13. 失败矩阵
## 12. 失败矩阵
| 条件 | 结果 |
| --- | --- |
@@ -495,12 +435,9 @@ Banner
| 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. 备选方案与取舍
## 13. 备选方案与取舍
- v2 原位改语义:拒绝;同一版本会同时表示 raw/normalized。
- v2/v3 双栈:拒绝;当前版本未发布,没有承担长期 raw 兼容的价值。
@@ -508,27 +445,22 @@ Banner
- Server 媒体代理:拒绝;超出本任务且增加 Cookie/授权/带宽边界。
- 保存 unsupported:拒绝;用户决定暂不处理,安全计数后跳过。
- raw 数据迁移:拒绝;开发数据精确清理后 full sync。
- 构建时固定下载 URL/稳定下载入口:拒绝;Server 直接发送 24h OSS 签名 URL
- 阻塞式升级弹窗:拒绝;顶部非模态 banner 不影响 OneTalk 使用。
- v2 升级 decoder、OSS 下载调用与页面横幅:拒绝;当前未上线,严格拒绝 v2,已有下载接口不在本 task 使用
## 15. 验证策略
## 14. 验证策略
1. 共享 contract:三分支 exact decoder、content version、升级 notice decoder
1. 共享 contract:三分支 exact decoder、content version 与 v2 hard-reject
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 媒体待样本。
5. IndexedDB v6:清空所有 OneTalk store保留 profile ledger 或重键分支,随后重新采集 profile 与 full sync。
6. Server:三分支入库/读取、duplicate、commit→ACK→publish 顺序,以及 profile/read-model 不再解析 raw media
7. PostgreSQL基于 `0004`新 migration、CHECK、旧列删除及所有 OneTalk 开发事实精确清理
8. Mind parityHTTP history 与 `message.created` 都返回 shared `OneTalkCenterMessage`,且对同一数据库行深度等价。
9. `/harness`:真实图片加载、文件按钮状态、history/live 去重
10. CDP smoke:真实历史 JPEG/ZIP/PDF;真实 live 媒体待样本
## 16. 回滚
## 15. 回滚
- 应用代码回滚必须与数据库 migration 协调;删除 `text/content_type` 后不能单独部署 v2 Server。
- 在正式部署前保留数据库备份;回滚 v2 需要恢复备份而不是从 normalized JSON 猜造旧 raw content
- 扩展 IndexedDB v5 清理不可逆,但仅清理可重新同步的消息状态;配置、deviceId 和 profile 可继续使用。
- OSS 签名功能可以通过回滚 Server 停止签发;已签 URL在 24 小时内仍可能有效,这是已知撤销窗口。
- 当前未上线,v6 IndexedDB 与 PostgreSQL OneTalk 开发事实清理不可逆;回滚仅适用于代码,不能恢复已清理的开发数据
@@ -0,0 +1,18 @@
# Finding ledger
Canonical ledger for the `09-02-onetalk-media-message-sync` implementation and
review rounds. Stable IDs remain unchanged across repair rounds.
| ID | Invariant | Severity/locus | Status | Owner | Reproducer/latest evidence |
| --- | --- | --- | --- | --- | --- |
| MEDIA-CONTRACT-001 | The shared package must own exact versioned text/image/file content and the public `OneTalkCenterMessage`; v2/raw message fields cannot remain current. | blocking_local | fixed | Goodall → Pascal | Pascal independently verified v3/content v1, raw-field exclusion, v2 rejection, exports, and 22/22 tests. |
| MEDIA-EXT-001 | No raw OneTalk `custom.data`, `contentType`, or independent top-level `text` may cross MAIN, persist locally, or upload; v5 state must not survive v6. | blocking_local | in_progress | Sagan → Zeno | Raw/bridge/v6 checks passed, but R-EXT remains open under MEDIA-EXT-003. |
| MEDIA-SERVER-001 | Server persistence must have one normalized JSONB content fact and no read-time raw decoder/parallel columns; the post-0004 migration must clear owned OneTalk facts. | blocking_local | open | R-SERVER implementer | Research confirms `content_type`/`text`/JSONB triple, `read-projection.ts` raw decoder, and no 0005 migration. |
| MEDIA-PARITY-001 | HTTP history and WS `message.created` must expose the same shared public message for one DB row while preserving commit→ACK→accepted-publish ordering. | blocking_local | open | R-MIND implementer | Research confirms HTTP legacy `CenterMessage` and WS internal `OneTalkMessage` diverge; current accepted ordering is verified and must be preserved. |
| MEDIA-REVIEW-001 | No independent checker report means the implementation is not accepted. | decision_required | closed | Pascal | R-CONTRACT checker terminal report and repair revalidation completed. |
| MEDIA-SMOKE-001 | Real Chromium JPEG/ZIP/PDF and live-media evidence must be distinguished from fixture success. | external_unverified | open | main-agent | Prior investigation verified history raw samples and bundle-level fields; end-to-end normalized live media remains unverified. |
| MEDIA-PG-001 | PostgreSQL migration and round-trip evidence require an isolated `TEST_DATABASE_URL`. | external_unverified | open | main-agent | Environment status not checked for this run. |
| MEDIA-CONTRACT-002 | Normalized media numeric metadata must have explicit domain upper bounds. | decision_required | fixed | Goodall → Pascal | Pascal revalidated 10 GiB media-size and 65,535px image-dimension bounds with exact boundary tests. |
| MEDIA-CONTRACT-003 | Contract boundary tests should cover legal null media URLs and unknown-kind/urlScope negatives. | non_blocking | fixed | Goodall → Pascal | Pascal revalidated legal null URL/download-state and unknown-kind/urlScope regression coverage. |
| MEDIA-EXT-002 | A valid message batch with page media diagnostics must mark its conversation checkpoint `succeeded_with_anomalies`. | blocking_local | fixed | Sagan → Zeno | Zeno revalidated both relevant conversation checkpoints as `succeeded_with_anomalies`; no anchor/candidate regression. |
| MEDIA-EXT-003 | Invalid MAIN message identity/direction must fail closed with a safe aggregate diagnostic, not disappear silently. | decision_required | in_progress | Goodall → Pascal → Sagan → Zeno | Pascal accepted shared guard (25/25); Sagan reports identity-before-media validation and 8/8 exact regressions, with full extension 214/214. Zeno revalidation pending. |
@@ -8,4 +8,4 @@
{"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 预签名与安全边界"}
{"file": ".trellis/tasks/09-02-onetalk-media-message-sync/research/bright-profile-integration.md", "reason": "132a376 的 profile/read-model 基线、v6 升级与统一 Mind 消息投影"}
@@ -20,14 +20,14 @@
- `OneTalkMessageContent`
- exact-shape content decoder/guard
- 媒体 URL、扩展名、数值和 downloadState 一致性校验
- `OneTalkCenterMessage`,作为 HTTP history 和 `message.created` 的唯一公开消息类型;内部 `OneTalkMessage` 仍只承担 normalized 入站/持久化域事实
2. 更新 `apps/onetalk-contract/src/model.ts`
- `ONETALK_PROTOCOL_VERSION=3`
- `OneTalkMessage/OneTalkObservedMessage` 使用 `OneTalkMessageContent`
- 删除顶层 `text/contentType`
- 定义带 target protocol/version、download URL、expiry 的 upgrade notice 类型
- 保持普通帧严格 v3;不新增 v2 升级 decoder 或下载 notice
3. 更新 `apps/onetalk-contract/src/decoder.ts`
- 普通帧严格要求 v3
- 增加版本无关但 exact-shape 的 `decodeOneTalkProtocolUpgradeNotice`
- raw/未知 content version fail closed
4. 更新包出口和 contract tests。
@@ -60,21 +60,21 @@ pnpm --filter @trade-message-center/onetalk-contract typecheck
- 字符串/Base64 嵌套 `chatToken` 不跨 decoder。
- history/live 同 fixture 深度等价。
### Phase 3Page bridge v2 与 IndexedDB v5
### Phase 3Page bridge v2 与 IndexedDB v6
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: 45`
5. v5 upgrade transaction 清空 message/candidate/checkpoint/anomaly stores,保留 profile store
4. `ONE_TALK_SYNC_DATABASE_VERSION: 56`
5. v6 upgrade transaction 清空 message/candidate/checkpoint/anomaly/profile store;删除旧 profile ledger 重键与兼容分支
6. 验证配置和 deviceId 所在 `chrome.storage.local` 不受影响。
7. 验证无 checkpoint 后首次启动进入 full sync,不恢复旧 pending raw。
8. 增加 v5 和更早 fixture,证明五个 OneTalk store 均被清空,v3 随后重新采集 profile 并走 full sync。
重点测试:
- raw `custom.data/text.extension` 无法穿过 page bridge。
- IDB v4→v5 清理精确且原子
- profile ledger 保留。
- IDB v5→v6 清理全部 OneTalk store 且原子;不保留 profile ledger 重键或 pending/ACK/rejected record
- candidate durability、ACK、断线恢复、anchor/completion 既有不变量不变。
### Phase 4Server normalized 存储与 PostgreSQL migration
@@ -88,12 +88,12 @@ pnpm --filter @trade-message-center/onetalk-contract typecheck
- 删除 `text/contentType` 映射
- history/event 直接返回 DB message
3. 生成新 Drizzle migration
- 删除 message/anomaly 开发数据
- 重置 conversation message_count/anchor/sync state
- 删除全部 OneTalk message/anomaly/profile/conversation 开发数据
- drop `text/content_type`
- add content version/kind CHECK
4. 更新 migration metadata,不编辑 `0000..0002`
5. 增加真实 PostgreSQL migration/round-trip/duplicate/pagination 测试
4. 从已合入的 `0003_onetalk_contact_profile_facts``0004_onetalk_conversation_direct_fact` 生成下一号 migration/snapshot/journal,不编辑既有 migration
5. migration 仅操作当前仓库拥有的 OneTalk 表;不触及其它渠道或跨系统授权/binding
6. 增加真实 PostgreSQL migration/round-trip/duplicate/pagination 测试,并断言清空后 v3 profile/direct discovery 能重新建立 list/detail 所需事实。
验证:
@@ -105,69 +105,25 @@ pnpm --filter @trade-message-center/server test:integration
`TEST_DATABASE_URL` 不存在,integration 明确报告 skipped,不宣称 PostgreSQL 已验证。
### Phase 5协议升级 OSS notice
### Phase 5Mind-facing history/event 与 `/harness`
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
1. 重构已合入的 `read-model.ts``read-projection.ts``read-repository.ts`、WS registry/contractHTTP history 和 `message.created` 都发布 shared `OneTalkCenterMessage`
2. 删除 `read-projection.ts` 的 Base64/UTF-8/JSON、`contentType/custom.type` 和 URL fallback 解码;它只能读取 normalized JSONB、投影语义 `readStatus`,不得保留 `unknown` raw fallback
3. 增加同一 DB message 在 history 与 `message.created` 中深度等价的跨层测试,并为 text/image/file 断言外发对象不含 `custom.data``contentType` 或顶层 `text`
4. 验证全量清理后 v3 profile/direct discovery 重新建立 list/detail;重建后的 name/avatar、query、cursor 与 `history_incomplete` 语义符合既有 read model
5. 更新 `/harness` renderer
- text 文本节点
- image 真 `<img>` + load/error 状态
- file 元数据 + 条件预览/下载按钮
- normalized JSON 调试区
4. 保持 history/live 同 Map 去重;不增加媒体发送 UI。
5. 外链安全属性与失败状态测试。
6. 保持 history/live 同 Map 去重;不增加媒体发送 UI。
7. 外链安全属性与失败状态测试。
### Phase 8:文档、规范和版本一致性
### Phase 6:文档、规范和版本一致性
1. 更新 OneTalk runtime/page-bridge/durable-sync/server DB/Mind history 规范。
2. 将最终媒体合同同步回相关 docs,清除旧 `cardType=0`、raw 可跨层等过时结论。
3. 更新 `.env.example`,只写变量名和安全占位,不写真实 OSS 信息
4. 校验根版本、子 package 版本和生成 manifest 的一致性;目标扩展版本配置必须符合现有版本校验规则。
3. 保留现有 `/api/downloads/chrome-extension` 原样,不新增下载 endpoint 调用、OSS signer/config、升级 notice、横幅或 manifest host permission
## 3. 全量验证
@@ -189,19 +145,18 @@ 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`
2. 真实历史 JPEG/ZIP/PDF:验证 MAIN normalized 输出、page bridge、IDB v6、Server DB、history 和 `/harness`
3. `/harness` 真实 `<img>` 加载和文件按钮状态;不自动下载二进制。
4. fake/controlled Server 发送 upgrade notice,验证 OneTalk banner、过期和恢复
5. 若没有真实 live 媒体样本,标记 `blocked_by_sample`,只报告 fixture 已通过。
4. 若没有真实 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 A`apps/onetalk-contract`,内容`OneTalkCenterMessage` 的唯一类型 owner。
- Workstream BChrome MAIN decoder、page bridge 与全清 OneTalk IDB v6
- Workstream CServer ingress/service/repository 与基于 `0004` 的全清 migration
- Workstream D已合入的 profile read-model/projection、Mind-facing HTTP/WS parity 与 `/harness`;只消费 A 的 `OneTalkCenterMessage`,不得重建 raw decoder
并行实现者不得修改其它 workstream 的 owner;公共类型变化由 A 先稳定,再由 B/C/D 消费。最终由主代理做跨层 diff review 和集成验证。
@@ -210,10 +165,10 @@ git diff --check
| 风险 | 控制 | 回滚点 |
| --- | --- | --- |
| v3 contract 影响所有 frame fixture | 先完成共享包与消费者编译 | Phase 1 commit |
| IDB 清理范围过宽 | store allowlist + profile preservation test | Phase 3 commit |
| PG migration 误删非消息事实 | SQL 精确表/列 + 隔离 DB test | migration 前备份 |
| IDB 清理范围过宽 | 五个 OneTalk store allowlist + v5/legacy fixture | Phase 3 commit |
| PG migration 误删跨域数据 | SQL 仅引用 `onetalk_*` + 隔离 DB test | migration 前备份 |
| profile read API 保留 raw decoder | 删除 `read-projection` raw parser,并以 history/live exact parity 和 payload negative assertions 约束 | Phase 5 commit |
| 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 未宣称验证 |
@@ -224,4 +179,3 @@ git diff --check
- 全量质量门禁通过。
- PostgreSQL integration 和真实 Chromium smoke 的执行/跳过/阻塞状态明确列出。
- diff review 未发现 raw payload、第二内容事实源、隐藏 fallback、跨账号路由或秘密日志回归。
@@ -17,6 +17,8 @@
- 当前 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,第一阶段没有新增媒体表的技术必要。
- 本 task 的实现基线是 `132a376c965aad1f968b23357bb0d71f9bae1db7`(Bright 会话客户资料合入)之后的代码:Bright 已以 `channelAccountId + conversationId` 保存 profile,并只将明确 `direct` 的会话暴露给读取 API。当前尚未上线,所有已持久化的 OneTalk 开发事实均可在媒体 migration 中全量清空,再由 v3 重新采集。
- 该基线将扩展 IndexedDB 升至 v5,并在 `apps/server/src/onetalk/read-projection.ts` 为 HTTP `CenterMessage` 再次解码 `contentType/custom.data`。后者与本 task 的 MAIN-only raw 边界冲突,也让 HTTP history 与 WS `message.created` 使用不同的媒体合同;本 task 必须移除该 Server 端 raw decoder,而不是在其上扩展图片/附件字段。
## Requirements
@@ -26,7 +28,8 @@
- 每个 `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`
- v3 `OneTalkMessage``OneTalkObservedMessage` 必须移除顶层 `text``contentType`history 和实时事件必须使用同一 `OneTalkCenterMessage`,并同样只从 `content.kind` 读取内容语义
- shared contract 还必须定义唯一的 Mind-facing `OneTalkCenterMessage`HTTP history 与 `message.created` 都返回它;它只携带语义化 `readStatus`、消息元数据和同一 `OneTalkMessageContent`,不得让 Server 内部的 raw-number 状态或 `contentType` 重新成为对外合同。
- 合法但暂不支持的业务卡片暂不进入同步业务链路:MAIN world 识别后跳过,不上传、不持久化、不发给 Mind,也不得伪装为文件、文本或空消息。
- 跳过必须产生仅含安全类型枚举/计数的 `unsupported_skipped` 诊断,不得包含消息 ID、正文、URL 或 raw payload。
- 跳过 unsupported 不得阻塞本批同步;未来纳入支持范围时必须通过 full sync 补回历史消息。
@@ -58,13 +61,15 @@
- 必须保持 `DB commit → message.ack → accepted 才发布 message.created` 的既有顺序;duplicate 不重复发布。
- 当前版本尚未正式发布,旧 raw 消息视为可丢弃开发数据,不实现 raw-to-normalized 数据迁移器或长期 read-time adapter。
- PostgreSQL 升级必须精确清理 OneTalk 旧消息事实和由其派生的消息计数/同步状态,再由 v3 执行全量重同步。
- PostgreSQL 清理不得删除账号、binding、联系人 profile 或其它渠道数据
- PostgreSQL migration 必须清空本仓库拥有的全部 OneTalk 开发事实:message、message anomaly、contact profile 与 conversation/direct fact;不得触及其它渠道或跨系统的授权/binding 记录
- 当前迁移序列已到 `0004_onetalk_conversation_direct_fact`;媒体 migration 必须从该事实继续编号并以 `DELETE` 清空数据,不回退或重写既有 profile/direct-fact migration。
### R3.1 扩展同步状态升级
- 扩展 IndexedDB 必须升级 schema 版本并清理旧 raw message、pending candidate、anchor/checkpoint 等同步状态。
- 扩展配置、deviceId、联系人 profile ledger 和其它渠道存储必须保留
- 扩展配置、deviceId 和其它渠道存储必须保留;OneTalk profile ledger 也作为无价值开发状态清空
- v3 首次启动必须从干净同步状态执行 full sync,不能恢复或上传任何 v2 raw candidate。
- 当前 v5 已完成 profile ledger 的账号/会话重键;本 task 必须从 v5 升至 v6。`oldVersion < 6` 时清空全部 OneTalk IndexedDB store,不保留或兼容旧 profile ledgerv3 启动后重新收集 profile 并 full sync。
### R4. 发给 Mind 的消息格式
@@ -74,6 +79,7 @@
- `/harness` 对未知文件类型使用通用文件卡片;只有已确认的 URL action 决定预览/下载能力,扩展名不决定动作。
- PDF 当前 payload 没有确定下载地址时,必须返回 `downloadUrl=null``downloadState="not_provided"`
- Mind 不得解析 OneTalk 的 `custom.data``msgType/subType` 或 raw SDK payload。
- `read-projection.ts` 不再做 Base64、UTF-8、JSON、`custom.type`、URL fallback 或 MIME/文件名猜测;它只校验/读取已持久化的 normalized `content`,并投影为 shared `OneTalkCenterMessage`。WS registry 必须发布同一投影类型,保证 history 与 live 对同一数据库行深度等价。
- 本任务不修改独立 `trade-mind` 仓库或正式 Mind UI。
- 当前仓库 `/harness` 是本任务唯一的 Mind 联调页面:history 与 `message.created` 必须进入同一媒体展示/去重路径。
- `/harness` 只承担调试展示和合同验收,不扩展为生产 UI,也不增加媒体发送入口。
@@ -98,27 +104,8 @@
### 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 和访问凭证不得进入日志、诊断或错误详情。
- v3 Server 不接受 v2 业务同步;当前未上线,不实现 v2 frame decoder、升级 notice、页面横幅、插件下载调用或 host permission
- 既有 `GET /api/downloads/chrome-extension` 与 WebSocket 同源,但不属于本 task 的接口、鉴权、OSS 或插件 UI 改造范围;未来需要已安装旧插件的升级流程时另行设计
## Acceptance Criteria
@@ -136,17 +123,14 @@
- [ ] `/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 其它渠道数据保持不变。
- [ ] v3 升级会精确清空扩展全部 OneTalk IndexedDB store,并触发 profile 重采集与 full sync;配置、deviceId、binding 其它渠道数据保持不变。
- [ ] v5 或更早的 IndexedDB fixture 升级到 v6 后,所有 OneTalk message/candidate/checkpoint/anomaly/profile ledger 均不存在;没有旧 profile 重键、重试或兼容分支遗留。
- [ ] PostgreSQL migration 删除旧 `text/content_type` 列;Server 所有写入和读取只使用 `content jsonb`
- [ ] PostgreSQL migration 清空 OneTalk message/anomaly/profile/conversation/direct-fact 开发数据;后续 v3 profile 观察与 direct discovery 能重新建立 list/detail 所需事实。
- [ ] profile task 的 HTTP history 与 WS `message.created` 对同一 text/image/file 数据库行返回深度等价的 `OneTalkCenterMessage`;断言 Server 源码和所有外发 payload 均不含 `custom.data``contentType` 或顶层 `text`
- [ ] 图片/附件 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。
- [ ] v2 业务帧被 v3 Server 直接拒绝;本 task 的 diff 不新增版本无关 decoder、下载 endpoint 调用、下载横幅、OSS signer/config 或 manifest host permission
## Out of Scope
@@ -156,9 +140,12 @@
- 未取得真实样本的文件类型运行态验收,除非后续明确纳入 MVP。
- 独立 `trade-mind` 仓库、正式 Mind 页面及其生产图片/附件 UI。
- `apps/mind-http-mock` 的消息存储或媒体展示;它不是消息消费端。
- 已有 Chrome extension 下载接口、OSS URL 签发、旧 v2 升级兼容、下载横幅和下载 host permission。
- 跨系统授权/binding 的删除或重建。
## Notes
- 本任务为跨插件、Server、数据库和 Mind 合同的复杂任务;完成 planning 前必须补齐 `design.md``implement.md`
- 运行态取数和字段合同参考 `docs/onetalk-media-message-sync-prd.md`,但 task PRD 的最终范围以本轮 grilling 决策为准。
- 当前没有真实 live 图片/附件 push 样本;实现必须覆盖 live 入口,但真实运行态验收状态只能标记为待样本补证。
- 本轮方案已按 `132a376c965aad1f968b23357bb0d71f9bae1db7` 的 profile/read-model 基线修订;它替换 Server raw-media read projection,以 v6 作为全量本地 OneTalk 状态重置号,并在 PostgreSQL 中全清 OneTalk 开发事实。
@@ -0,0 +1,21 @@
# `132a376` 对媒体同步 task 的整合证据
## 已确认的基线
- `132a376c965aad1f968b23357bb0d71f9bae1db7` 合入 profile current facts 和 Bright conversation read model;本分支的 HEAD 已以它为祖先。
- 扩展 `ONE_TALK_SYNC_DATABASE_VERSION` 已为 `5`。v5 的升级逻辑在旧库中重键 `onetalk_contact_profiles`;该 ledger 与 message/candidate/checkpoint/anomaly store 共用一个 IndexedDB database,但资料 API 独立读写它。
- Server migration 已到 `0004_onetalk_conversation_direct_fact``0003` 创建没有 conversation 外键的 `onetalk_contact_profile``0004` 增加 `onetalk_conversation.conversation_kind`;新的公开读取只暴露明确 `direct` 的会话。
- profile task 新增的 `read-projection.ts``onetalk_message.content_type/content` 解析文本、图片和附件,含 Base64/UTF-8/JSON、`custom.type` 和 URL fallback。这条 Server-side raw decoding 路径与媒体 task 的 MAIN-world-only raw boundary 相冲突。
- 当前 HTTP history 输出 `CenterMessage`,实时 `message.created` 输出内部 `OneTalkMessage`;两者不能满足媒体 task 要求的同一内容合同和 deep-equality parity。
## 方案结论
1. IndexedDB 升级号改为 v6。当前尚未上线,v6 清空全部五个 OneTalk store(包括 profile ledger);不保留 `<5` profile 重键或旧 pending/ACK/rejected 记录,随后重新采集 profile 与 full sync。
2. 媒体 migration 从 `0004` 后继续,清空 message、anomaly、profile 与 conversation/direct fact 全部 OneTalk 开发事实;跨系统授权/binding 和其它渠道数据不属于此 migration。
3. shared contract 同时拥有 normalized `OneTalkMessageContent` 与对外 `OneTalkCenterMessage`。HTTP 和 WS 都输出后者;Server 的 read projection 只做公开字段/read-status 投影,不再解释 raw OneTalk content。
4. 对 text/JPEG/ZIP/PDFhistory 和 live 读取同一条数据库行必须逐字段相等;测试同时否定 `custom.data``contentType`、顶层 `text` 出现在任何出站 Mind payload。
## 已决范围排除
- 当前未上线,v3 直接拒绝 v2;不实现版本无关 upgrade decoder、升级 notice、插件下载调用、下载横幅或 manifest host permission。
- 当前 Server 的 `GET /api/downloads/chrome-extension` 保持原样。它与 WebSocket 同源,但不由本 task 修改或调用;未来旧插件升级流程另立任务。
@@ -0,0 +1,246 @@
# Research: contract-extension-current
- Query: 研究当前 `apps/onetalk-contract` 与扩展/Server 直接消费者的 OneTalk v3/content v1 契约现状,核对 normalized text/image/file、`OneTalkCenterMessage` parity、v2 hard-reject、raw-field exclusion 与编译耦合。
- Scope: internal
- Date: 2026-09-03
## Findings
### 1. Files found
#### Shared contract
- `apps/onetalk-contract/src/model.ts`:协议常量、消息/观察消息类型、所有 frame 类型和 frame 构造器。
- `apps/onetalk-contract/src/decoder.ts`WebSocket frame 的运行时解码、方向/字段/权限校验和协议版本拒绝。
- `apps/onetalk-contract/src/index.ts`:当前 package facade,仅 re-export `authorization.ts``decoder.ts``model.ts`
- `apps/onetalk-contract/test/contract.test.ts`:当前 21 个 Node tests;没有 normalized media fixture。
- `apps/onetalk-contract/package.json``tsconfig.json`package 只通过 `dist` exports 对外提供类型/runtime,构建产物包含 source/test 编译结果。
#### Extension consumers
- `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts`:MAIN 观察消息的本地开放类型,仍包含 `contentType`、raw `content` 和顶层 `text`
- `apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts``history.ts``new.ts`:按 WebSocket envelope 定位历史/新消息,但没有共享内容 decoder。
- `apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts`:旁路解析并把 raw frame、parsed message 写入页面 console。
- `apps/chrome-extension/src/onetalk/page-bridge/model.ts``isolated.ts``main.ts`v1 generic JSON page bridgeISOLATED 仅做 source/origin/方向转发。
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts``observation-pipeline.ts`:将页面消息复制为 contract `OneTalkObservedMessage`,再按会话持久化/上传。
- `apps/chrome-extension/src/onetalk/service-worker/storage.ts`IndexedDB message/candidate/checkpoint/anomaly/profile ledger 和 v5 migration。
- `apps/chrome-extension/src/onetalk/service-worker/bright-client.ts`:构造并 JSON 序列化 `message.observed`,所有 frame 使用共享 `ONETALK_PROTOCOL_VERSION`
- `apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts``service-worker/page-send-outcome.ts`:使用共享 `isOneTalkMessage` 完成 sent message 收窄;当前依赖旧 message 字段。
#### Server consumers
- `apps/server/src/onetalk/model.ts`Server domain port 以 shared `OneTalkMessage`/`OneTalkObservedMessage` 作为写入、读取和发布类型。
- `apps/server/src/onetalk/service.ts`:当前第二套 raw message validator/sanitizer,负责从观察消息组装旧 `OneTalkMessage`
- `apps/server/src/onetalk/repository.ts``apps/server/src/database/schema/onetalk.ts`:写读 `content_type``text``content jsonb` 三份内容字段。
- `apps/server/src/onetalk/read-model.ts``read-repository.ts``read-projection.ts``read-service.ts`HTTP read model;当前本地 `CenterMessage` 与 read-time Base64/raw media decoder。
- `apps/server/src/websocket/registry.ts``websocket/handler.ts`WS `message.created` 发布、DB/ACK/publish 顺序和 frame decode 入口。
- `apps/server/src/http/onetalk.ts``http/harness.ts`HTTP history 类型与 `/harness` 的独立 runtime validators/renderer。
- `apps/server/drizzle/0000_rapid_winter_soldier.sql``0004_onetalk_conversation_direct_fact.sql`:当前 migration 序列;`0004` 是新增媒体 migration 的基线。
#### Relevant task/spec artifacts
- `.trellis/tasks/09-02-onetalk-media-message-sync/prd.md`:最终验收要求,明确 v3/content v1、MAIN-only raw、DB 清理与 history/live parity。
- `.trellis/tasks/09-02-onetalk-media-message-sync/design.md`:拟议类型、decoder 所有权、bridge v2、IDB v6、Server read boundary 和 acceptance probes。
- `.trellis/tasks/09-02-onetalk-media-message-sync/implement.md`:实施顺序与包级验证命令。
- `.trellis/tasks/09-02-onetalk-media-message-sync/research/current-cross-layer-evidence.md`:此前跨层 raw 泄漏证据。
- `.trellis/tasks/09-02-onetalk-media-message-sync/research/runtime-media-contract.md`:此前真实历史 JPEG/ZIP/PDF raw 样本及 live/URL 未验证边界。
- `.trellis/spec/guides/cross-layer-thinking-guide.md`:要求为跨层 payload 建立单一 decoder/normalizer owner。
- `.trellis/spec/project/module-ownership.md`:要求共享领域类型只有一个 owner 和 package facade。
- `.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md``durable-sync.md`:页面桥、durable write、ACK/anchor 既有边界。
- `.trellis/spec/server/backend/database-guidelines.md``service-foundation.md`DB fact、duplicate 和 `commit → ACK → publish` 顺序。
### 2. Current shared contract is v2 and raw-compatible
`apps/onetalk-contract/src/model.ts:3` exports `ONETALK_PROTOCOL_VERSION = 2``OneTalkJsonValue` at `:152-158` is a recursive arbitrary JSON type and is still used by generic non-message payloads such as send command/request and anomaly payloads.
The persisted message type at `model.ts:211-224` currently has:
```text
messageId, conversationId, senderId, direction, sentAtMs,
content: OneTalkJsonValue, contentType: number, text?: string | null,
participantIds, readStatus, messageStatus, unreadCount
```
`OneTalkObservedMessage` at `model.ts:226-240` is even looser: all values are covered by an open index signature and all message fields are optional, including `content`, `contentType`, and `text`.
The current runtime guard `isOneTalkMessage` at `decoder.ts:234-250` checks required identity/status fields, arbitrary JSON `content`, numeric `contentType`, and optional string/null `text`; it does not reject extra message keys. `isOneTalkObservedMessage` at `decoder.ts:252-255` accepts any plain JSON object without required message fields or an exact key allowlist. Consequently, a v2 `message.observed` can carry raw OneTalk content, top-level `text`, `contentType`, `custom.data`, or arbitrary nested fields.
The frame decoder at `decoder.ts:465-486` first checks `value.protocolVersion !== ONETALK_PROTOCOL_VERSION`: missing version returns `invalid_message`, any present non-2 version returns `onetalk_protocol_upgrade_required`, and only version 2 is then payload-validated. This means the mechanism is already fail-closed for non-current versions, but current tests only prove versions 99 and 1 are rejected (`contract.test.ts:91-117`); after changing the constant to 3, a dedicated v2 rejection test is still required.
The payload rules at `decoder.ts:410-427` are the key current message boundaries:
- `message.observed` validates only `observationSource` and the open `OneTalkObservedMessage` object.
- `message.created` validates `isOneTalkMessage`.
- `send.request` and `send.command` still accept arbitrary JSON `content` (`decoder.ts:424-427`); the task design changes the message fact content, not the out-of-scope media-send input.
- `send.confirmation` and `send.result` use `OneTalkMessage` for `confirmed_sent` (`decoder.ts:428-447`), so their sent-message fixtures and page confirmation path are compile/runtime coupled to the new normalized message shape.
The package facade at `apps/onetalk-contract/src/index.ts:1-5` has no `content.ts` or `OneTalkCenterMessage` export. `package.json:6-15` exposes only `./dist/src/index.d.ts` and `./dist/src/index.js`; extension/server package imports therefore consume generated declarations/runtime, not source directly. The workspace symlinks point at `apps/onetalk-contract` and the generated package output is ignored under `apps/onetalk-contract/dist/`.
### 3. Current contract fixtures prove the opposite of the target contract
`apps/onetalk-contract/test/contract.test.ts:44-60` defines the baseline observed/complete message with `content: { text: "hello" }` and a numeric top-level `contentType`; the complete fixture omits top-level `text`, but this is not a normalized content object.
`contract.test.ts:177-191` accepts the same sparse raw observed message for all four observation sources (`history`, `incremental`, `live`, `send_confirmation`). `contract.test.ts:401-425` is explicitly named “keeps legacy observed frames decodable while raw observations remain JSON objects”: it asserts that malformed known fields inside an observed message are still accepted and that the raw fixture round-trips unchanged. This test must be inverted/replaced for v3 rather than retained as a compatibility assertion.
`contract.test.ts:427-466` verifies that `message.created` and confirmed send results require all persisted old fields, including numeric `contentType` through `completeMessage`. `contract.test.ts:706-745` proves decoder failure results do not echo credentials, but there is no corresponding raw `custom.data`/`chatToken` message fixture because arbitrary observed content is currently accepted.
No contract fixture currently covers:
- `version: 1` discriminated `text`, `image`, or `file` content;
- exact-shape rejection, unknown/missing content version, empty-string/null URL semantics, download state consistency, or URL host/action policy;
- real JPEG image metadata or ZIP/PDF file metadata;
- `cardType=12` versus unsupported `cardType=2000`;
- raw top-level `text`/`contentType` exclusion;
- a shared `OneTalkCenterMessage` payload.
The existing Server read-domain fixtures are raw-media fixtures, not shared-contract fixtures. `apps/server/test/onetalk-read-domain.test.ts:69-99` creates Base64 JSON image/attachment metadata, and `:365-426`, `:428-474`, `:476-567` assert the old read-time `contentType: "text" | "img" | "attachment" | "unknown"` output and fallback behavior. These are useful source-shape evidence but must not become the v3 normalized contract.
### 4. Extension currently carries raw content across every boundary
`apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:5-29` defines a local `ObservedOneTalkMessage` with an open index signature plus raw `contentType`, `content`, and top-level `text`. `copyOptionalFields` at `:89-115` copies the page `content` object wholesale, extracts `content.contentType`, and derives `text` from `content.text.content`; non-text messages receive `text = null`. No normalized content is produced.
`message-observer/index.ts:9-29` parses a JSON string only far enough to require `code === 200`, then routes `body.userMessageModels` to `parseHistoryMessages` or an array body to `parseNewMessages`. `history.ts:13-33` and `new.ts:8-47` only locate envelope/message/participant fields and call the same raw `observedMessage`; they do not decode media. Thus history and new-message collection share an entry point but currently share only the raw mapper, not a content contract.
`message-observer/websocket.ts:48-73` logs non-heartbeat raw frame data at `:53-55`, then logs every parsed message object at `:64-68`; both logs can contain raw content. The task's raw-field exclusion must remove this side effect or replace it with safe type/count diagnostics before any cross-page emission.
The page bridge is version 1 (`apps/chrome-extension/src/onetalk/page-bridge/model.ts:10-24`). `decodeObservedMessage` at `:202-209` checks only plain-object/JSON validity and copies every key. `decodeObservedMessageEnvelope` at `:211-233` accepts that object in `batch`; `createOneTalkPageObservedMessage` at `:351-363` emits it unchanged. `isolated.ts:41-87` performs source/origin and direction checks but no business-field interpretation. Therefore raw `custom`, `custom.data`, `text.extension`, credentials embedded as strings, and unknown message keys can cross MAIN → ISOLATED → Port.
The Service Worker repeats the open-copy behavior. `sync-engine/helpers.ts:66-79` copies every page key to contract `OneTalkObservedMessage`, maps legacy `sentAt` to `sentAtMs`, and if `content` is absent synthesizes `{ text: message.text }` or `null` from the top-level text. This is a second content fallback that must disappear once MAIN emits normalized content.
`storage.ts:25-76` stores the local observer-shaped message in `StoredOneTalkMessage` and candidate records. `validObservationFields` at `:492-521` checks generic JSON content, numeric `contentType`, and optional top-level text; it does not validate a content union. `toStoredOneTalkMessage` at `:289-304` spreads the whole message, while `createCandidate` at `:561-580` spreads the whole message into the pending candidate. Consequently, raw fields can be durably retained before upload.
`storage.ts:16-23` is currently schema version 5. `openDatabase`/`openSyncDatabase` at `:399-430` only clean legacy `loginUserId` and migrate the profile ledger for old versions; no v6 full OneTalk store clear exists. The five stores are explicitly enumerated at `:331-343`, which gives the implementer the allowlist for the required atomic `oldVersion < 6` clear.
`observation-pipeline.ts:140-217` converts page batches, groups them by conversation, and then awaits per-conversation persistence. `:267-297` calls `persistObservedBatch`, updates checkpoint state, and releases/continues candidate processing. `ack-completion.ts:157-186` reads candidates, writes pending/request state, then calls Bright; `:243-300` marks candidates confirmed/anomaly/rejected only after ACK handling. These existing await/mutation boundaries must remain unchanged while the stored message shape changes.
`bright-client.ts:330-344` constructs `message.observed` with the contract's open observed message, and `:787-813` sends it after `decodeOneTalkFrame` is run in `sendFrame` (`:508-551`). Once the shared decoder requires normalized content, this is the final extension-side runtime gate before the WebSocket side effect.
`send-observation.ts:41-64` derives text from either top-level `message.text` or raw `message.content.text.content`, and `:58-64` calls `isOneTalkMessage` for a complete sent fact. `page-send-outcome.ts:10-22` has a fixed old `MESSAGE_FIELDS` list containing `contentType`; `:69-104` uses `isOneTalkMessage` to accept confirmed sent messages. Although media sending is out of scope, text send confirmation must be updated to the normalized text content and its diagnostics must no longer require or list `contentType`/top-level `text`.
### 5. Server currently has three content representations and a read-time raw decoder
The Server domain imports shared old message types (`apps/server/src/onetalk/model.ts:3-14`). `OneTalkHistoryPage` and `OneTalkMessageInsertResult` use `OneTalkMessage` (`:40-60`); `observeMessage` accepts raw `OneTalkObservedMessage` (`:191-196`). This is the correct outer service seam to retain, but the inner message content must become the shared normalized union.
`apps/server/src/onetalk/service.ts:29-44` has an old `MESSAGE_FIELDS` allowlist including `contentType` and `text`. `normalizeMessage` at `:171-245` validates generic JSON, numeric `contentType`, optional top-level text, recursively removes keys matching `SENSITIVE_KEY_PATTERN`, and constructs a message with all three representations at `:230-243`. `observeMessage` at `:436-460` records an anomaly or calls guarded repository insertion. This is currently the Server's second raw parser/sanitizer; under the target boundary it must consume the shared normalized decoder result and must not parse `custom.data` or perform raw-media fallback.
The sanitizer is not a sufficient raw exclusion boundary: `sanitizeJsonValue` at `service.ts:82-104` filters object keys only. A sensitive field embedded in a string or Base64 JSON is not interpreted. The current malformed-observation test demonstrates only key-based cleaning (`apps/server/test/onetalk-domain.test.ts:292-371`). The target requires raw content to be rejected before Server persistence, not sanitized into a partial raw object.
`apps/server/src/database/schema/onetalk.ts:51-110` defines `onetalk_message` with `contentType` (`:77-78`), nullable `text` (`:79-80`), and generic `content` JSONB (`:81-82`). `repository.ts:37-51` reads all three into `OneTalkMessage`; `messageValues` at `:97-122` writes all three. The message idempotency key remains correctly scoped to `channelAccountId + conversationId + messageId` (`repository.ts:81-91`, schema `:100-107`) and must not change for media.
Duplicate behavior is already an invariant: `repository.ts:306-365` inserts in a transaction, increments conversation count only on actual insert, and on conflict updates `lastObservedAt` then returns the existing DB row. The task design explicitly requires the existing normalized row to remain authoritative; a duplicate must not replace it with later raw/normalized content.
The server read path is currently a separate content contract:
- `read-model.ts:28-45` defines local `CenterMessage` as a union discriminated by `contentType: "text" | "img" | "attachment" | "unknown"`, not by `content.kind`.
- `read-model.ts:60-71` exposes raw numeric `contentType` and generic JSON to the read repository.
- `read-repository.ts:117-130` maps DB rows with raw `contentType` and `content`.
- `read-projection.ts:20-23` defines raw numeric content constants; `:41-66` performs Base64/UTF-8/JSON decoding; `:68-116` performs permissive URL fallback, including `thumbnailUrl`; `:118-150` identifies raw text/custom types; and `:171-230` emits the old local `CenterMessage` union.
- `read-service.ts:201-217` invokes `projectCenterMessage` for every history row.
This directly conflicts with the required MAIN-only raw boundary. The read projection must become a normalized-content validator/reader plus `readStatus` projection; it must not parse Base64, `custom.type`, raw URLs, or MIME/file-name guesses.
History and live are not currently parity-equivalent. HTTP uses `CenterMessage` from the read projection (`apps/server/src/http/onetalk.ts:10-15,95-103`), while the WS registry accepts an internal `OneTalkMessage` at `apps/server/src/websocket/registry.ts:110-142,489-510` and `createOneTalkMessageCreatedFrame` currently types `payload.message` as `OneTalkMessage` (`apps/onetalk-contract/src/model.ts:452-458`). The handler preserves the existing side-effect order at `apps/server/src/websocket/handler.ts:865-910`: await service/DB result, send plugin ACK, then publish only `accepted`; `duplicate` does not publish. This order is a required preservation point, but the published object must become the shared `OneTalkCenterMessage` read shape, not the internal numeric-status/raw shape.
`apps/server/src/http/harness.ts:150-190` embeds another old contract: `isCenterMessage` requires top-level `contentType` and old content forms, while `isLiveMessage` at `:167-169` requires numeric `contentType`, `messageStatus`, and `unreadCount`. `renderMessages` at `:267-289` only JSON-stringifies content, and the live event path at `:395-415` separately validates the old live message. This is a direct parity and second-validator gap; the harness needs to consume the shared `OneTalkCenterMessage` shape and one renderer path.
The current Server schema/migrations also remain raw-compatible. `0000_rapid_winter_soldier.sql` creates `content_type` and `text`; `0001``0004` do not remove them. The task explicitly requires a next migration after `0004_onetalk_conversation_direct_fact` that clears owned OneTalk facts, removes those two columns, and adds the light JSON object/version/kind check. No application-side raw-to-normalized adapter should be built because the task treats current development data as disposable.
### 6. Target contract delta and implementation coupling
The task design at `.trellis/tasks/09-02-onetalk-media-message-sync/design.md:50-145` assigns the shared contract owner to a new `apps/onetalk-contract/src/content.ts` and requires:
- `ONETALK_CONTENT_VERSION = 1`;
- exact-shape `OneTalkTextContent`, `OneTalkImageContent`, `OneTalkFileContent`, and `OneTalkMessageContent` discriminated by `kind`;
- nullable media URLs with `urlScope: "onetalk_session"` and consistent `downloadState`;
- `OneTalkMessage.content: OneTalkMessageContent` with no top-level `text` or `contentType`;
- shared `OneTalkCenterMessage` with semantic `readStatus` and the same normalized content, owned/exported by the contract package;
- shared runtime guards/decoder that reject missing/unknown content version and extra fields.
The target raw decoder owner is `apps/chrome-extension/src/onetalk/main-page/message-observer/content-decoder.ts` (`design.md:147-179`). It should be the only module that understands `contentType/custom.type/custom.data`; history/new/live must only locate messages and invoke it. The raw decoder must map text (`contentType=1`), image (`101 + custom.type=7`), and file (`101 + custom.type=10010 + decoded cardType=12`) and return safe unsupported/anomaly diagnostics without raw payload, URL, message ID, or body.
The current direct consumers create this dependency order:
1. Contract: add content types/guards and `OneTalkCenterMessage`; raise protocol constant to 3; update frame payload types and tests; export from `src/index.ts`.
2. Extension MAIN: produce normalized content before `window.postMessage`; make history/new/live use one decoder; change bridge to v2 and reject raw/unknown keys; then update SW storage/candidate/bright/send-confirmation types.
3. Server ingress/storage: remove raw sanitizer as a content parser, accept only shared normalized content, update repository/schema and next migration; preserve duplicate and commit/ACK fences.
4. Server read/event/harness: remove read-time raw decoder, map DB content to shared `OneTalkCenterMessage`, make history and live publish the same object, and use `content.kind` in the harness.
There is no TypeScript project-reference coupling. The package export coupling is instead generated-output based: `apps/chrome-extension/package.json:8-14` and `apps/server/package.json:8-25` run `pnpm --filter @trade-message-center/onetalk-contract build` before their own dev/build/typecheck/test scripts; root `package.json:11-17` also explicitly builds the contract before parallel consumers. An implementer must build the contract after every public type change before typechecking extension/server. Direct source-only tests can import `src/index.ts`, but package consumers resolve the `dist` declarations/runtime.
`OneTalkJsonValue` should not be removed indiscriminately: current non-message generic command/anomaly/page-bridge types use it. The required narrowing is specifically message content and the page observation payload; any remaining generic JSON boundary must be kept out of normalized message facts and must not be reused as a media fallback.
### 7. Verified commands and current baseline
Read-only checks executed in this run:
- `pnpm --filter @trade-message-center/onetalk-contract typecheck` — passed.
- `node --experimental-strip-types --test apps/onetalk-contract/test/contract.test.ts` — passed, 21/21 tests.
- `pnpm --filter @trade-message-center/chrome-extension typecheck` — passed; its package pre-hook rebuilt the contract before checking.
- `pnpm --filter @trade-message-center/server typecheck` — passed; its package pre-hook rebuilt the contract before checking.
These are only current-baseline compile/tests. They do not prove any v3 behavior, media normalization, DB migration, raw-field exclusion, history/live parity, or browser runtime behavior.
## Invariants and Acceptance Probes
### Invariant ownership
- Normalized content schema, exact guard, content version, and `OneTalkCenterMessage` owner: `apps/onetalk-contract/src/content.ts` plus the package facade. Extension, Server, and harness must import this owner rather than define equivalent unions.
- Raw OneTalk interpretation owner: MAIN-world `message-observer/content-decoder.ts`. Raw `contentType/custom.type/custom.data` must not be understood by the bridge, Service Worker, Server service, read projection, or harness.
- Persisted content fact owner: PostgreSQL `onetalk_message.content` JSONB. `contentType`, `text`, and raw read-time projections must not remain as independently writable content facts.
- History/live read owner: one Server projection of the same committed DB row into `OneTalkCenterMessage`; registry and HTTP must not create different media representations.
- Message identity owner remains `channelAccountId + conversationId + messageId`; media kind/content does not participate in the idempotency key.
### Snapshot, await, mutation, and side-effect boundaries
1. MAIN receives raw OneTalk WebSocket JSON in `message-observer/index.ts:10-29`; `observedMessage` currently snapshots raw content at `model.ts:93-115`. The normalized decoder must finish before the sink call in `page-script-entry.ts:32-35` and before `page-bridge/main.ts:100-109` posts a message.
2. Page bridge currently snapshots all JSON keys at `page-bridge/model.ts:202-233`; this is the raw-leak boundary to eliminate. `isolated.ts:54-75` has no await and only forwards the decoded envelope.
3. Service Worker turns the page batch into contract messages (`helpers.ts:66-79`), then `observation-pipeline.ts:267-297` awaits IndexedDB persistence/checkpoint updates. Candidate writes at `storage.ts:603-646` must contain only normalized content; malformed media/unsupported cards must not create message/candidate records.
4. `ack-completion.ts:157-186` awaits candidate state writes before Bright `sendMessageObserved`; this durable-write-before-upload boundary must remain. `:243-300` awaits candidate/ACK state mutations; only accepted/duplicate may become confirmed and only valid accepted facts may advance anchors.
5. Server `handler.ts:865-910` awaits `service.observeMessage`, sends `message.ack`, and only then awaits `registry.publishMessageCreated` for `accepted`. The DB transaction is inside `repository.ts:306-365`; `duplicate` returns the existing row and must not publish. Guard checks around async DB work are already present and must remain.
6. HTTP history currently awaits read repository/projection in `read-service.ts:201-217`, while live publication gets the service result directly in the registry. The target requires both paths to snapshot the same normalized content and semantic read status from the same DB fact, with no raw re-decoding after the DB boundary.
### Deterministic acceptance probes
- Contract shape matrix: v3 frames carrying valid text/image/file content decode successfully; missing/unknown `content.version`, wrong `kind`, extra fields, empty strings, invalid numbers, inconsistent `downloadState`, invalid URL policy, top-level `text`, top-level `contentType`, and raw `custom.data` all fail closed with no raw value in the failure result.
- Protocol matrix: v2, v1, 0, 99, and missing protocol version are rejected; present non-v3 versions return `onetalk_protocol_upgrade_required`, missing version returns `invalid_message`. Test v2 explicitly because it is the deployed predecessor.
- Export/compile probe: import every new type/guard from `@trade-message-center/onetalk-contract`, rebuild the package, then run extension and Server typechecks. Verify no consumer imports a private duplicate media type.
- History/live equivalence: feed the same sanitized text/JPEG/ZIP/PDF fixture through history and live observer entry points; assert deep equality of normalized message content and absence of raw fields before page-bridge creation.
- Bridge exclusion: pass raw `custom.data`, nested serialized `chatToken`, `text.extension`, and unknown keys to `decodeOneTalkPageMessage`; assert `null`/rejection. Serialize accepted bridge, IDB candidate, and Bright frame and assert none contains those fields.
- Unsupported/anomaly matrix: `cardType=2000` yields only aggregated `unsupported_skipped`; invalid Base64, fatal UTF-8, JSON, size, schema, and URL cases yield the stable anomaly code/media kind; none creates a candidate or blocks another valid message.
- Storage/ACK matrix: with one valid and one invalid/unsupported item, assert only the valid item is stored/uploaded; accepted/duplicate ACKs confirm it; anomaly/rejected do not advance the anchor; duplicate does not publish.
- Server boundary matrix: raw v3 observed messages are rejected/anomalized before DB insert; normalized text/image/file are persisted in `content` JSONB; DB failure produces no success ACK/publish; v2 frame is rejected before service invocation.
- Read parity matrix: insert one DB row for each content kind, obtain HTTP history and a `message.created` publication for that same row, and assert deep equality of the message object. Assert neither output has top-level `text`, `contentType`, `messageStatus`, `unreadCount`, `custom.data`, or raw SDK fields.
- Harness matrix: render each `content.kind`; image preview success/error and file preview/download available/not-provided states remain visible; no automatic download; history/live enter the same deduplicating Map and renderer.
- Migration/upgrade matrix: v5 and older IndexedDB fixtures containing all five OneTalk stores upgrade to v6 and leave all five stores empty while preserving extension configuration/deviceId/other-channel storage. PostgreSQL migration after `0004` clears only owned OneTalk message/anomaly/profile/conversation/direct facts, drops `text/content_type`, and leaves authorization/binding/other-channel data untouched.
### Validation commands for implementer/checker
Run after the contract change, in dependency order:
```bash
pnpm --filter @trade-message-center/onetalk-contract test
pnpm --filter @trade-message-center/onetalk-contract typecheck
pnpm --filter @trade-message-center/chrome-extension test
pnpm --filter @trade-message-center/chrome-extension typecheck
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
```
`contract test`, extension/server package scripts, and root quality commands rebuild or consume generated contract `dist`; run the contract build before any direct consumer check. `test:integration` must report an explicit skip when `TEST_DATABASE_URL` is absent. Real Chromium/CDP history JPEG/ZIP/PDF and real live-media push remain separate smoke evidence; fixture success must not be reported as live end-to-end success.
## Caveats / Not Found
- No `apps/onetalk-contract/src/content.ts` or `OneTalkCenterMessage` exists in the current contract package.
- No shared normalized media decoder exists in MAIN, and no normalized text/image/file fixture exists in the contract or extension tests.
- Current tests intentionally preserve raw observed frames and old read-time media projection; they are migration targets, not evidence of v3 compatibility.
- Current `/harness` has independent old HTTP/live validators and JSON-only rendering; it is not evidence of media UI behavior.
- No PostgreSQL integration database was configured or checked in this research run; migration execution, column removal, JSON CHECKs, and cleanup scope still require isolated `TEST_DATABASE_URL` evidence.
- No real live image/file push was verified in this run. Prior task research records real historical raw JPEG/ZIP/PDF samples, while live media, URL Cookie/Origin/long-lived access, upload/download, and send flows remain unverified (`research/runtime-media-contract.md`).
- The extension/server typecheck commands passed their package pre-hooks, which rebuild the ignored contract `dist`; this proves the current baseline compiles but does not prove that a future consumer check ran against the intended v3 API until the contract package is rebuilt after the implementation change.
- No external documentation was used; the OneTalk raw field semantics cited here are repository task research/runtime evidence, not independently verified vendor documentation.
@@ -0,0 +1,225 @@
# Research: OneTalk extension runtime current media normalization
- Query: 核对当前 Chrome extension 的 OneTalk 媒体观察、history/live 路径、MAIN/ISOLATED/page bridge、Service Worker candidate/ACK/sync、IndexedDB 以及测试;确认 raw `content.custom.data/contentType/text` 的边界传播、unsupported/anomaly 行为和 v5→v6 reset 影响。
- Scope: internal / mixed(内部代码、测试和任务研究;媒体样本结论引用既有本地运行态报告)
- Date: 2026-09-03
## Findings
### Files found
- `apps/chrome-extension/src/onetalk/main-page/message-observer/model.ts:13-147`:当前页面观察消息模型、字段复制和方向解析。
- `apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts:10-30``history.ts:14-34``new.ts:8-48``websocket.ts:40-75`WebSocket 帧分流、history/live 解析和旁路日志。
- `apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts:39-85``all-conversations.ts:139-227`:只读 history SDK 请求、分页和进度边界。
- `apps/chrome-extension/src/onetalk/main-page/sync-push-decoder.ts:1-203`:独立的 Base64 MessagePack decoder;当前未被消息 observer 调用。
- `apps/chrome-extension/src/onetalk/page-bridge/model.ts:10-82,202-233,295-363`page envelope 和 observed batch decoder。
- `apps/chrome-extension/src/onetalk/page-bridge/main.ts:45-109``isolated.ts:41-88`MAIN 发布、ISOLATED 转发和 source/origin/方向边界。
- `apps/chrome-extension/src/onetalk/service-worker/runtime.ts:284-309,376-420,509-620`:Port 入站、持久化前置和精确页面命令路由。
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine/helpers.ts:66-78``observation-pipeline.ts:79-217,238-337`:页面消息再映射、按会话串行持久化和 history progress。
- `apps/chrome-extension/src/onetalk/service-worker/storage.ts:16-185,289-415,492-646,660-856`:v5 数据库、五个 store、通用字段校验、候选和 anomaly 写入。
- `apps/chrome-extension/src/onetalk/service-worker/sync-engine/ack-completion.ts:66-187,189-241,243-435``sync-engine.ts:178-270`candidate drain、ACK、completion 和 anchor snapshot。
- `apps/chrome-extension/src/onetalk/service-worker/configured-sync-session.ts:125-280``sync-engine/bootstrap-coordinator.ts:116-289,291-420`:配置生命周期、恢复和 bootstrap。
- `apps/onetalk-contract/src/model.ts:3-40,211-240,406-413,452-458,490-509``decoder.ts:234-255,325-448,464-487`:当前 shared contract v2 和通用 JSON decoder。
- `apps/chrome-extension/test/onetalk-websocket-tap.test.js:105-325``onetalk-page-bridge.test.js:139-205,326-367`:当前 raw message、bridge 和日志测试。
- `apps/chrome-extension/test/onetalk-current-conversation-history.test.js:97-185,474-544`history SDK 和群聊跳过测试。
- `apps/chrome-extension/test/onetalk-sync-storage.test.js:15-315``onetalk-sync-engine.test.js:13-25,237-419,894-1263`v2/v5 storage fixture、durability、ACK、anchor 和恢复测试。
- `.trellis/tasks/09-02-onetalk-media-message-sync/research/runtime-media-contract.md:10-100``current-cross-layer-evidence.md:3-64`:既有真实历史媒体样本和跨层现状证据。
### Current control and data flow
#### Live WebSocket path
1. `page-script-entry.ts:32-37` 安装 `installOneTalkMessageObserver`,观察回调先交给 `sendObservation.observe(batch)`,再交给 `createOneTalkPageObservedSink(window)`
2. `message-observer/websocket.ts:48-57` 只监听 `wss-icbu.dingtalk.com`,收到 string 后调用 `parseOneTalkMessages``websocket.ts:53-56` 会先尝试心跳识别,再由 parser 解析 JSON。
3. `message-observer/index.ts:14-29` 仅接受 `code === 200``body.userMessageModels``parseHistoryMessages`,数组 body 走 `parseNewMessages`
4. live `new.ts:12-33``singleChatUserConversation.lastMessage.message` 取消息;history `history.ts:19-31``userMessageModels[].message` 取消息。两者最后都调用 `model.ts:124-146``observedMessage`
5. `model.ts:89-115``message.content` 作为 `content` 完整复制,将 `content.contentType` 复制为顶层 `contentType`,只从 `content.text.content` 提取顶层 `text`;没有读取、解码或筛选 `content.custom.data`
6. 因此当前媒体不会在 MAIN 被拒绝:`contentType=101``custom.type=7/10010`、Base64 字符串和其他 raw 字段均属于开放 JSON,可以继续进入 sink。`observedMessage` 对缺失字段只返回部分观察对象,完整性由后续同步边界处理。
7. `websocket.ts:53-68` 当前还会将原始 WebSocket frame 记录到 page console,并将解析后的消息对象记录到 console;raw frame 日志没有媒体/URL 脱敏。sink 异常被吞掉,不能影响页面 WebSocket 行为(`websocket.ts:59-72`)。
#### History path and history/live divergence
- `current-conversation-history/sdk.ts:55-84` 调用 `fetchMessagesWithoutUpdateToRead`,请求带 `conversationCode: conversation.cid`、联系人账号和 `timeSlide`,避免改变已读状态;返回值只被 `normalizeHistoryMessagePage` 归一化为消息 ID/时间和 `hasMore`,不是完整 raw message。
- `all-conversations.ts:139-227` 以时间戳分页并通过 `onProgress` 发布页边界。真正的完整 history message 仍由 OneTalk WebSocket response 的 `body.userMessageModels` 被 observer 旁路观察;所以 history SDK 的分页结果与 history message observer 是两条关联但不同的输入。
- 当前 history/live 的媒体语义没有两套 decoder:二者共用 `observedMessage`,但 envelope、状态来源和参与者来源不同。history 使用 item 级 `readStatus/msgStatus``cid` 推导 participants`history.ts:19-31`);live 使用 `lastMessage/readStatus/msgStatus``pairFirst/pairSecond``new.ts:12-31`)。未来 normalized decoder 应放在它们共同调用的 MAIN 层,不能分别在 history/live 分支补字段。
- `sync-push-decoder.ts:194-203` 能够解码 OneTalk `/s/sync` 的 Base64 MessagePack,但 `rg` 证据显示它只有测试直接调用,没有任何生产 observer 调用。`websocket.ts` 对 sync push 不是消息 JSON 的响应会只保留 raw console log(测试见 `onetalk-websocket-tap.test.js:327-340`)。因此“live”当前至少包含普通 JSON WebSocket message path 和未接入的 sync-push path;不能把 MessagePack decoder 的测试通过当成 live 媒体已接入。
#### MAIN / page bridge / ISOLATED
- MAIN sink `page-bridge/main.ts:100-109` 只把 batch 包入 `onetalk.page.observed`,发送前调用 `decodeOneTalkPageMessage`;该 decoder 在 `page-bridge/model.ts:202-208` 对每个 observed item 仅要求 plain JSON,并把所有键原样复制。
- `page-bridge/model.ts:14-24,35-40` 的 page message/observed message 类型允许开放字段;`decodeObservedMessage` 不做 exact key allowlist、不拒绝 `custom``custom.data``contentType` 或顶层 `text`
- `page-bridge/isolated.ts:54-75` 只做当前 window source、origin、bridge version 和消息方向检查,然后 `port.postMessage``pageWindow.postMessage`;它不解释消息内容、不做大小或媒体 schema 验证。
- `page-bridge/main.ts:78-97` 对来自 ISOLATED 的命令只验证 envelope 和方向;观察消息的入站/出站均没有 normalized content boundary。MAIN→ISOLATED 的 raw propagation 因此是同步的、无 await 的 `window.postMessage → port.postMessage` 链路。
- `page-bridge/model.ts:10-12` 当前 bridge version 是 1shared protocol version 与 page bridge version 是两个独立边界,当前分别为 v2 和 1。
#### Service Worker page Port and observation pipeline
- `service-worker/runtime.ts:515-554``tab.id:frameId` 注册唯一 page connection`runtime.ts:384-418` 只接受已 decode 且方向正确的 page envelope。`onetalk.page.hello` 更新 page identity`onetalk.page.observed` 进入 observation dispatch。
- `runtime.ts:284-309` 如果配置了 `persistPageObservation`,先等待它完成,再调用 `onPageMessage`;当前实际注入由 `page-runtime-host.ts:143-163` 完成,调用 `engine.handlePageObservation` 后才返回。因此 page observation 的 durable boundary 位于 Service Worker 的 engine pipeline,而不是 Port 转发本身。
- `observation-pipeline.ts:140-217` 将 page batch 用 `pageMessageToObserved` 转换,按 `messageType` 选择 history/live source,再按 conversation 分组并逐项串行持久化。它不会在 SW 解释 `content.custom.data`
- `helpers.ts:66-78` 会复制所有 page message 键;若 `content` 缺失但 `message.text` 存在,会构造 `{ text: message.text }` 作为 content`helpers.ts:72-74`)。这是当前 legacy fallback,也是一个会重新制造独立文本事实的边界。
- `observation-pipeline.ts:238-297` 读取/创建 checkpoint,决定 full/incremental candidate mode,调用 `store.persistObservedBatch`;按会话的 `observationWrites` map 保证异步写入串行。不同 conversation 可通过 `Promise.all` 并行。
#### IndexedDB schema and raw persistence
- 当前 `ONE_TALK_SYNC_DATABASE_VERSION``ONE_TALK_MESSAGE_DATABASE_VERSION` 都是 5`storage.ts:16-23`)。五个 store 是 `onetalk_messages``onetalk_sync_checkpoints``onetalk_sync_candidates``onetalk_sync_anomalies``onetalk_contact_profiles`,均由 `ensureSyncStores` 创建(`storage.ts:331-344`)。
- 业务幂等 key 是 `[channelAccountId, conversationId, messageId]``storage.ts:187-201`);checkpoint key 是 `[channelAccountId, conversationId]`。这与扩展规范要求的 identity/sync scope 一致。
- `persistObservedBatch` 在同一个 readwrite transaction 中处理 message、candidate、anomaly 三个 store`storage.ts:603-645`)。正常消息先 `messageStore.put`,再按 candidate key 合并 candidate;字段非法则只写 anomaly,不创建正常 message/candidate。该顺序是页面观察到 SW durable write 的安全边界。
- 但当前 `validObservationFields` 只验证必填身份、时间、递归 JSON、数字 `contentType`、参与者和可选 string `text``storage.ts:492-521`),不会验证 `content.kind/version`,也不会识别 raw media、unsupported card 或 Base64/schema/URL anomaly。
- `toStoredOneTalkMessage``createCandidate` 都保留观察对象的全部业务键(`storage.ts:289-304,561-580`);candidate 内的 `message` 也仍是 `OneTalkObservedMessage`,所以 raw `custom.data` 会同时进入 message store 和 candidate store。
- `createOneTalkMessageStore``storage.ts:660-680`)仍提供只写 message store 的旧接口,`writeBatch` 没有 normalized validation;当前 active configured session 使用 `createOneTalkSyncStore``configured-sync-session.ts:247-264`),但这个兼容导出仍是潜在 raw 写入 surface,测试在 `onetalk-service-worker-storage.test.js:92-118` 覆盖了它。
- v5 profile ledger 已经按 `channelAccountId + conversationId` 重新建 key`storage.ts:366-397` 的旧 migration 逻辑会保留最新 pending profile,且 `openDatabase/openSyncDatabase:399-435` 仅在 oldVersion `<3` 清理 `loginUserId`、oldVersion `<5` 迁移 profile。v5→v6 当前代码没有 reset 分支。
#### Bright candidate / ACK / sync flow
- `ack-completion.ts:133-187` 只从 `pending_ack` candidate 读取,先 `store.updateCandidate(..., requestId)`,再调用 `bright.sendMessageObserved`。因此 upload side effect 位于 candidate durable write 之后。
- `bright-client.ts:330-344,787-813` 将 observation source 和 message 原样放入 `message.observed` frame`bright-client.ts:508-561` 在 socket/auth/scope guard 后执行 `JSON.stringify(frame)` 和 WebSocket send。当前 shared decoder 允许该 raw message 通过,因为 `isOneTalkObservedMessage` 只要求所有值是 JSON`decoder.ts:252-255`)。
- `sync-engine.ts:224-254` 收到 `anchor.snapshot``message.ack` 后分别交给 anchor/completion 和 ACK coordinator。`ack-completion.ts:243-317``accepted`/`duplicate` 变为 `confirmed``anomaly`/`rejected` 变为对应终态,更新 checkpoint 并尝试 completion。
- `ack-completion.ts:189-241` 只有 history complete、无 pending candidate、Bright 在线时才发送 `sync.complete`;发送成功后 checkpoint 保持 `uploading`、写 `completionSent=true``ack-completion.ts:320-351` 只有收到精确匹配的 `anchor.snapshot` 才将 checkpoint 改为 `completed` 并停用 queue。
- candidate 状态和 anchor 不变量当前是可复用的:`awaiting_anchor` 不上传,只有已确认 candidate 才能产生 `latestConfirmedMessageId``helpers.ts:202-220`);坏消息若被正常写入,当前实现仍可能被 ACK/anchor 处理,未来 skip/anomaly 必须在 candidate 创建前截断。
- `bootstrap-coordinator.ts:243-289` 重启后从 IndexedDB checkpoint 恢复 scanning/awaiting_anchor/uploading`bootstrap-coordinator.ts:291-420` 首次 bootstrap 读取 Bright 的 anchor snapshot,并以远端 `latestMessageId === null` 选择 full,否则选择 incremental。扩展本地没有一个独立的“v6 已清理后强制 full”状态。
### Raw field boundary assessment
| Boundary | 当前行为 | `custom.data/contentType/text` 风险 |
| --- | --- | --- |
| OneTalk WebSocket → MAIN parser | `JSON.parse` 后开放复制 `content`,提取 `contentType``text` | raw content、Base64、顶层 text 全部留在 MAIN 对象 |
| MAIN console | 原始 frame 与解析对象均可能打印(`websocket.ts:51-68` | URL、Base64 和业务字段可进入开发 console |
| MAIN → page bridge | 只验证 JSON envelopeobserved item 任意 JSON | raw `custom.data` 直接跨 window/page boundary |
| ISOLATED → Port | 无状态 source/origin/方向转发 | 不做 normalized/schema/size guard |
| Port → SW pipeline | 先异步 durable persist,再通知上层 | `pageMessageToObserved` 原样复制并有顶层 text fallback |
| IndexedDB message/candidate | generic JSON/contentType/text 校验,原样写入 | raw 同时进入 message 和 candidate store |
| Bright `message.observed` | shared v2 frame decoder 只验证通用 JSON | raw 被 JSON 序列化上传 |
| Server/DB/Mind-facing | 由现有 server raw contract 消费;不属于本 extension-only owner | 需依赖 shared contract/server migration 共同收口,扩展单侧不能证明最终不落库 |
### Unsupported and anomaly behavior currently
- 当前没有 `unsupported_skipped` 诊断或安全类型计数。`custom.type=10010` 不论 decoded `cardType`,只会作为普通 JSON 进入观察和 candidate`cardType=2000` 不会被跳过。
- 当前没有媒体 anomaly decoder。非法 Base64、fatal UTF-8、非法 JSON、超限、缺字段、非法 URL 都不会在 MAIN 产生媒体 anomaly;只要外层字段满足 generic JSONstorage 也不会把它视为异常。
- storage 的 anomaly 只覆盖通用 observation completeness,例如 missing message/conversation/sender、invalid direction/time/contentType/participants/readStatus/status/unreadCount/text`storage.ts:492-558`)。这些 anomaly 会在同批继续处理其它消息,但不具有媒体错误码语义。
- page bridge 仅在 envelope、progress、JSON value 或 profile exact shape 不合格时拒绝(`page-bridge/model.ts:295-319`);observed item 本身没有 exact field guard,所以“bridge decode 成功”不等于 raw 未跨界。
- MAIN observer 的 JSON parse/parser/sink 异常大多被吞掉(`websocket.ts:51-72`),这保护页面,但也意味着当前没有可观测的脱敏 anomaly 计数边界。
- 现有 group skip 只发生在历史会话发现:`normalizeConversationEntry` 将 group 标记为 skipped`all-conversations.ts:246-253` 不启动该会话 history;它不等价于媒体业务卡片 unsupported skip。live 帧中的群聊/非 direct identity 当前只能因参与者/方向/会话字段缺失在后续 anomaly 或不完整观察处置,不能视为已完成群聊隔离。
### Shared contract dependency
- `apps/onetalk-contract/src/model.ts:3` 当前 `ONETALK_PROTOCOL_VERSION = 2``OneTalkMessage``model.ts:211-224` 同时拥有 `content: OneTalkJsonValue`、数字 `contentType` 和可选顶层 `text``OneTalkObservedMessage``226-240` 也允许这三个字段并允许任意索引键。
- `decoder.ts:234-250``isOneTalkMessage` 只验证这些字段的基础类型;`decoder.ts:325-448``message.observed` 只验证 observation source 和 generic observed message,对 `message.created` 只调用 generic complete message guard。没有 content version、kind、media schema 或 raw-key rejection。
- message observed frame 的类型仍是 `payload.message: OneTalkObservedMessage``model.ts:406-413`),message created 仍是 `payload.message: OneTalkMessage``model.ts:452-458`)。因此 extension runtime 改成 normalized content 必须先同步 shared contract,否则 bright-client 的 compile/type guard 和服务端 ingress 仍会把 raw/normalized 混在同一 v2 边界。
- `bright-client.ts:530-551` 在发送前调用 shared `decodeOneTalkFrame`shared contract 是 Bright outbound 的最后本地 guard,但不是当前的 raw sanitizer。
- 目标任务设计将 normalized content 的唯一 owner 放在 `apps/onetalk-contract`MAIN 只产出该 contract 的 observed unionISOLATED/SW 不再解释媒体;该 ownership 与当前文件分层相容,但需要删除当前 generic fallback 和开放 page observed keys,不能在现有 generic guard 上叠加媒体例外。
### Exact v5 → v6 reset impact
#### Verified current v5 behavior
1. 打开数据库时 `ensureSyncStores` 确保五个 OneTalk store 存在(`storage.ts:331-344`)。
2. oldVersion `<3` 仅清理 message/candidate 中的 `loginUserId``storage.ts:346-364`)。
3. oldVersion `<5` 仅迁移旧 profile ledger key,按 pending observed time 选择记录(`storage.ts:366-397`)。
4. oldVersion 为 5 时当前 `onupgradeneeded` 不删除任何 message、candidate、checkpoint、anomaly 或 profile 记录;因此当前 v5 能恢复 raw pending candidate/checkpoint/profile。
5. 配置、binding、channelAccountId 和 deviceId 不在 IndexedDB,而在 `chrome.storage.local``service-worker-entry.ts:135-174,294-300``config.ts` 的 device/config keys);当前数据库 migration 不触碰它们。
#### Required v6 behavior from task design, not yet present in code
- `ONE_TALK_SYNC_DATABASE_VERSION` 应从 5 升到 6;任何 `oldVersion < 6` 应在同一个 versionchange transaction 内清空以下五个 store`onetalk_messages``onetalk_sync_candidates``onetalk_sync_checkpoints``onetalk_sync_anomalies``onetalk_contact_profiles`
- 清理结果是:旧 message、raw candidate、pending ACK correlation 的 durable record、历史分页/anchor checkpoint、历史 anomaly、v5 profile ledger 以及 profile pending/uploaded/rejected 记录全部消失;不保留 v5 profile 重键或兼容 retry 分支。
- `chrome.storage.local` 的扩展 config、binding 和 deviceId 不受影响;这不应被实现为清空整个 extension storage。
- v6 清空不可逆。IndexedDB versionchange transaction 可以原子提交或 abort,但一旦 commit 没有应用内 rollback;任务 design 同时将 PostgreSQL OneTalk 开发事实清理视为不可逆,代码回滚不能恢复数据。
- 由于没有 checkpoint,后续恢复从空本地 sync ledger 开始;但“自然 full sync”依赖 Bright 的远端 anchor snapshot 也为空或 `latestMessageId=null`。当前 bootstrap 明确按远端 anchor 选择 mode`bootstrap-coordinator.ts:338-367`),所以扩展 v6 清理单独不能证明 full sync;必须与服务端清空 `onetalk_conversation`/direct fact 的 migration 和新 `anchor.snapshot` 联动验证。
- 首次启动的实际顺序是:配置创建 active session`configured-sync-session.ts:125-280`)→ page host replay/page ready → Bright connect/auth → anchor snapshot → `maybeBootstrap`。v6 migration 在 `createOneTalkSyncStore` 或 profile store 第一次读写时懒打开,必须验证 migration 完成后再出现任何 profile/message/candidate side effect。
### Structural gate: invariant ownership and side-effect boundaries
#### Invariant and owner
- Invariant:任何跨出 MAIN 的 OneTalk message 都必须是经过一次完整 Base64 → UTF-8 → JSON → field/schema/URL validation 的 versioned normalized `text | image | file` unionraw `custom.data`、raw `contentType`、完整 SDK 对象和顶层独立 `text` 不得跨 bridge、进入 IndexedDB、candidate、Bright frame 或 Server。
- Recommended owner`apps/chrome-extension/src/onetalk/main-page/message-observer/` 中 history/new 共用的 MAIN decoder 负责 raw→normalized`apps/onetalk-contract` 负责跨系统 normalized type/decoderpage bridge 负责 exact envelope guardSW storage/ACK 只负责 durable state 和 delivery semantics,不再拥有媒体解释逻辑。
- Source of truthnormalized `content` 是唯一消息内容事实;message identity 仍是 `[channelAccountId, conversationId, messageId]`checkpoint/anchor 只能由有效 candidate 的 ACK 结果推进。
#### Snapshot / await / mutation / irreversible boundaries
| Boundary | Snapshot/await/mutation | Required property for media invariant |
| --- | --- | --- |
| MAIN WebSocket callback | raw `data` parse and `observedMessage` are synchronous; no await | Decode and validate before `sink`/console; do not retain raw in returned object |
| MAIN page post | `postPageMessage` synchronous; bridge decoder runs before `postMessage` | Page message exact guard must reject raw fields and unknown keys |
| ISOLATED Port | `port.postMessage` / `pageWindow.postMessage` synchronous | Remain content-agnostic; forward only already-safe normalized envelope |
| SW dispatch | `persistPageObservation` awaited before `onPageMessage` (`runtime.ts:300-305`) | Persist only normalized message; failure must prevent upper success notification |
| Per-conversation pipeline | `observationWrites` serializes promises (`observation-pipeline.ts:306-316`) | One raw fixture cannot race a second normalizer or bypass durable validation |
| IndexedDB observation | one readwrite transaction, then await `transactionResult` (`storage.ts:611-645`) | Invalid media writes anomaly only; no message/candidate creation |
| Candidate upload | await candidate update, then Bright `send` (`ack-completion.ts:157-175`) | No upload before durable normalized candidate |
| ACK | frame handler async; candidate lookup/update and checkpoint mutation await queue (`ack-completion.ts:243-317`) | Only accepted/duplicate valid normalized candidate may confirm/advance |
| Completion/anchor | `sync.complete` send is side effect; completion checkpoint remains uploading until matching anchor (`ack-completion.ts:189-241,320-351`) | Skipped/anomaly message IDs cannot become latest anchor |
| v6 reset | versionchange transaction clear is destructive/irreversible after commit | Allowlist exactly five stores; config/device storage remains untouched; no stale in-memory store may be reused before migration |
#### Feasibility and locus of control
- The extension can prove the MAIN/page-bridge/IDB/Bright boundary locally with unit fixtures and negative key assertions. It cannot prove Server DB/history/WS parity or actual media URL usability without the shared contract/server implementation and running Bright/PostgreSQL.
- The current repository has no application hook that maps raw `content` to normalized media before `createOneTalkPageObservedMessage`; the feasible control point is the common MAIN observer model/decoder, not `storage.ts` or `read-projection.ts`.
- Adding a second media decoder in SW or Server would leave the existing raw crossing intact and create history/live or plugin/Server sources of truth. Design work should therefore stabilize shared normalized contract and MAIN decoder first, then narrow bridge and storage guards.
- The current v5 local reset and planned PostgreSQL reset have different owners. Extension code can clear local stores, but only Server migration can clear remote anchors that determine bootstrap mode.
### Focused acceptance / test matrix
| Area | Fixture / mutation | Expected evidence |
| --- | --- | --- |
| Text | current OneTalk text content | MAIN emits `{version:1, kind:"text", ...}`; no top-level `text/contentType` in bridge/IDB/Bright payload |
| Image | `contentType=101`, `custom.type=7`, valid Base64 JSON; real JPEG fields | normalized `image`; dimensions/size/suffix/preview status preserved; no raw `custom` crosses MAIN sink |
| File | `contentType=101`, `custom.type=10010`, valid decoded `cardType=12`; ZIP and PDF fixtures | normalized `file`; generic safe suffix/name/size; ZIP download candidate; PDF preview-only and `downloadUrl=null` when absent |
| Unsupported card | decoded `cardType=2000` or another legal unsupported card | one safe `unsupported_skipped` count; no message/candidate/bridge/ Bright send; same batch valid messages continue |
| Base64/UTF-8/JSON | invalid Base64, fatal UTF-8, malformed JSON | stable media anomaly; no candidate; no raw value, URL, message ID or payload in diagnostic |
| Schema/size/URL | missing field, wrong type, over-limit data, invalid protocol/whitespace/URL action | stable anomaly; no silent text/null fallback; batch proceeds |
| Raw key boundary | raw `custom`, `custom.data`, `text.extension`, unknown keys at page envelope/message/candidate | page decoder returns null or normalized exact shape; `JSON.stringify` of bridge/IDB/Bright input contains no raw key/value |
| History/live parity | same normalized fixture wrapped once in `userMessageModels`, once in live `lastMessage` array | deep equality of normalized content and shared metadata semantics; only source/progress metadata differs where contract allows |
| MessagePack live boundary | current sync-push encoded text/media-like samples | explicitly prove whether `/s/sync` is wired; if not wired, mark live sample not covered rather than treating decoder unit test as runtime coverage |
| Durability | valid + invalid messages in one page batch; inject IDB transaction failure | valid normalized candidate persists; invalid only anomaly; failed durable write causes no Bright send/upper success callback |
| ACK/anchor | accepted, duplicate, anomaly, rejected ACK; skipped message ID; completion then matching/non-matching anchor | accepted/duplicate confirm; anomaly/rejected do not advance success anchor; skipped ID cannot complete anchor; completion stays uploading until exact snapshot |
| Port lifecycle | wrong origin/source/version, disconnect during command/persist | rejected/no raw forwarding; pending command delivery unknown; no local queue in ISOLATED; page observation durability remains explicit |
| v5→v6 | v5 fixture with nonempty all five stores, plus v4/v0 legacy fixture | upgrade to v6 atomically leaves all five stores empty; no old profile key/retry branch; config/deviceId and unrelated Chrome storage remain |
| v6 bootstrap | v6-empty local DB with Bright anchor snapshot empty vs nonempty | empty remote anchors produce full; nonempty remote anchor produces incremental under current code, demonstrating why Server reset is required for full-sync acceptance |
| Runtime samples | real JPEG/ZIP/PDF history; real live image/file push | history sample can be verified from existing evidence; live requires new push sample and must remain `blocked_by_sample` until captured |
## Verified facts, hypotheses, and external/unverified boundaries
### Verified in current worktree
- Current extension implementation is v5, shared protocol is v2, page bridge is v1.
- MAIN observer copies raw content and extracts top-level `contentType`/`text`; history and live use the same `observedMessage` but different response envelopes.
- Page bridge and shared protocol accept arbitrary JSON observed messages; SW storage writes raw content to message/candidate stores after only generic field validation.
- Candidate durable write precedes Bright send; ACK and completion/anchor state machine has explicit async boundaries and does not treat `sync.complete` send alone as completed.
- Existing automated tests use text fixtures and a bare `custom.type=10010` history shape without decoded `cardType` or `custom.data`; they assert raw content remains present (for example `onetalk-websocket-tap.test.js:270-312`) rather than normalized media behavior.
- Existing storage tests cover v2→current migration removing `loginUserId`, not v5→v6 destructive reset (`onetalk-sync-storage.test.js:230-315`).
### Existing local runtime/sample evidence
- `runtime-media-contract.md:3-8` records a previous Chrome 154/CDP run with extension runtime version 0.8.6 and unchanged page/read state.
- `runtime-media-contract.md:10-37` records a real history JPEG signature: raw `contentType=101`, `custom.type=7`, Base64 JSON, SDK `msgType=102/subType=60`, and `originalData` dimensions/size/suffix/url.
- `runtime-media-contract.md:39-81` records real ZIP/PDF attachment signatures: raw `custom.type=10010`, decoded `cardType=12`, ZIP `fileAction=download`, PDF `fileAction=officePreview`, both with empty payload `downloadUrl`.
- `runtime-media-contract.md:83-93` records a legal `cardType=2000` non-file card; it is evidence for required unsupported skip, not evidence that current code skips it.
### Hypotheses / needs implementation verification
- A common MAIN decoder can normalize both history and live ordinary JSON frames because both already converge at `observedMessage`; this is an architectural feasibility conclusion, not current runtime behavior.
- The planned v6 reset will cause first local synchronization to start without local checkpoints, but full vs incremental remains controlled by remote Bright anchors until the Server data reset is deployed and observed.
- The `sync-push-decoder.ts` MessagePack path may be a separate live ingress for some events, but current source has no production call site; media support there requires a product/runtime decision and real frame evidence.
### External / unverified boundaries
- No real live image/attachment push sample is available; existing report explicitly marks this gap (`runtime-media-contract.md:95-100`).
- Media URL behavior without OneTalk cookies, from Mind origin, across time, and for PDF download is unverified; do not infer public/permanent URLs or invent download actions.
- No current-turn browser/CDP run was performed against a live OneTalk page; the implementation and tests in this worktree do not prove v6 migration or real runtime normalized output.
- Server-side normalized ingress, PostgreSQL migration, HTTP history, WS `message.created`, and `/harness` parity are cross-layer deliverables; extension-only evidence cannot close them.
## Related specs
- `.trellis/spec/chrome-extension/frontend/onetalk/history-sync.md`history SDK、只读分页、群聊显式 skip、真实 envelope 和账号身份。
- `.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md`MAIN/ISOLATED/SW bridge、source/origin、Port 和命令路由。
- `.trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md`durable-first、candidate/ACK、checkpoint、anchor snapshot、重启恢复和 profile ledger 独立性。
- `.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md``runtime-diagnostics.md`:运行时生命周期、状态和脱敏诊断。
- `.trellis/spec/guides/cross-layer-thinking-guide.md``code-reuse-thinking-guide.md`:跨层事实源、共享 owner 和避免重复 decoder。
## Caveats / Not Found
- GitNexus 当前只索引 `Trellis` 仓库,没有可用的 `trade-message-center-worktree` knowledge-graph index;本报告的符号/调用证据来自当前 worktree 的 source/test 直接读取。
- 未找到当前生产代码中的媒体 normalized decoder、`unsupported_skipped` 计数器、媒体 anomaly code、v6 migration、v2→v3 content guard 或 live MessagePack media wiring。
- 当前 worktree 在研究开始前已有用户/其他 agent 的未提交修改;本轮未触碰这些文件,也未读取或修改 `implement.jsonl` / `check.jsonl`
- v5→v6 清空范围必须使用五个明确 store 名称,不能对 `chrome.storage.local` 或整个 IndexedDB 数据库调用 broad clear;事务 abort/blocked 行为和已打开数据库连接的真实浏览器时序仍需实现后 smoke 验证。
@@ -1,4 +1,6 @@
# OSS 私有插件包下载研究
# OSS 私有插件包下载研究(已移出当前 task 范围)
> 这是早期方案的历史记录。当前 task 不修改或调用 `/api/downloads/chrome-extension`,也不实现 v2 升级 decoder、下载 notice、横幅或 OSS signer;不得将本文作为本 task 的实施依据。
## 1. 结论
@@ -0,0 +1,127 @@
# Research: Server/Mind-facing OneTalk media persistence and parity
- Query: Inspect the current Server/Mind-facing implementation after the profile/direct-fact baseline for normalized OneTalk text/image/file persistence, migration shape, raw decoder duplication, ACK/publish ordering, HTTP history, `message.created`, and `/harness` behavior.
- Scope: internal / mixed (runtime dependencies and external PostgreSQL, Mind, and Chromium evidence are called out separately)
- Date: 2026-09-03
## Findings
### Baseline after profile/direct-fact integration
- The current source contains the profile/direct-fact baseline. `0003_onetalk_contact_profile_facts.sql` creates `onetalk_contact_profile` with composite key `(channel_account_id, conversation_id)` and deliberately no conversation foreign key (`apps/server/drizzle/0003_onetalk_contact_profile_facts.sql:1-19`). `0004_onetalk_conversation_direct_fact.sql` adds nullable `onetalk_conversation.conversation_kind` and documents that old unknown rows remain null (`apps/server/drizzle/0004_onetalk_conversation_direct_fact.sql:1-4`). The Drizzle journal ends at index/tag 4, so the next migration is 0005 (`apps/server/drizzle/meta/_journal.json:4-39`).
- Direct discovery is written by `discoverConversation`, which upserts `conversationKind` in the conversation table (`apps/server/src/onetalk/repository.ts:153-180`). The read repository exposes only rows with `conversation_kind = 'direct'` and joins profile facts on the same `(channel_account_id, conversation_id)` (`apps/server/src/onetalk/read-repository.ts:64-77`, `132-176`). A profile may exist before a technical conversation; this is intentional and is covered by the profile migration shape and read integration fixture (`apps/server/test/onetalk-read-postgres.integration.test.ts:65-107`).
- The read model currently projects direct conversations with profile `name`/`avatarUrl`, latest persisted message, sync state, and fixed public `participantIds=[]`/`unreadCount=0` (`apps/server/src/onetalk/read-model.ts:12-26`; `apps/server/src/onetalk/read-repository.ts:51-62`). The profile/direct facts are therefore the current list/detail source of truth and must survive structurally after the media reset migration, even though all existing OneTalk rows are intentionally disposable development facts.
### Current schema and exact next migration shape
- `onetalk_message` currently has three independently populated content representations: required integer `content_type`, nullable text `text`, and required JSONB `content` (`apps/server/src/database/schema/onetalk.ts:51-82`). The initial migration confirms the same physical columns and comments (`apps/server/drizzle/0000_rapid_winter_soldier.sql:26-48`, `77-98`). This is the old v2 shape; the schema has no content-version or content-kind constraint (`apps/server/drizzle/meta/0004_snapshot.json:267-353`, including `checkConstraints: {}` for the table).
- The next migration must continue after 0004, without editing 0003/0004 or the migration ledger history. The task design specifies this exact sequence:
1. `DELETE FROM onetalk_message_anomaly` and `DELETE FROM onetalk_message`.
2. `DELETE FROM onetalk_contact_profile` and `DELETE FROM onetalk_conversation`; this removes direct facts, message counts, sync state, and anchors so v3 can rebuild them.
3. `ALTER TABLE onetalk_message DROP COLUMN text` and `DROP COLUMN content_type`.
4. Add a light PostgreSQL CHECK over `content`: JSON object, `content->>'version' = '1'`, and `content->>'kind' IN ('text','image','file')`. Exact branch shape, URL policy, numeric limits, and `downloadState` consistency remain shared-decoder responsibilities, not SQL responsibilities (`.trellis/tasks/09-02-onetalk-media-message-sync/design.md:359-368`).
- The cleanup scope is exactly the four repository-owned OneTalk fact tables: message anomaly, message, contact profile, and conversation. There is no authorization/binding table in the Server schema; the migration must not delete cross-system authorization records or non-OneTalk data. The Drizzle migration ledger/schema itself must remain intact. `apps/server/src/database/migrate.ts:54-65` applies committed migrations and closes the client; it does not provide an application rollback path. The destructive boundary therefore needs an isolated PostgreSQL migration test, not a user-database reset.
- The schema change should also change `onetalkMessage.content.$type` from generic `JsonValue` to the shared normalized `OneTalkMessageContent`, while retaining generic `JsonValue` for anomaly payloads (`apps/server/src/database/schema/onetalk.ts:19-25`, `81-82`, `202-229`). The SQL CHECK is only a database integrity fence; it cannot replace the shared exact decoder.
### Current Server ingress, normalization, persistence, ACK, and publish path
- The current shared protocol is v2 (`apps/onetalk-contract/src/model.ts:1-4`). `OneTalkMessage` contains normalized-looking outer fields but still has arbitrary recursive JSON `content`, required numeric `contentType`, and optional top-level `text` (`apps/onetalk-contract/src/model.ts:211-240`). `isOneTalkObservedMessage` accepts any JSON-valued object, while `isOneTalkMessage` validates only generic JSON plus the legacy fields (`apps/onetalk-contract/src/decoder.ts:234-255`). Thus the Server boundary currently does not reject raw media semantics.
- `message.observed` enters the authenticated plugin branch at `apps/server/src/websocket/handler.ts:865-927`. The handler calls `service.observeMessage` with the frame message and a connection commit guard (`867-873`). On success it creates and sends `message.ack` (`874-901`), then only for `accepted` awaits `registry.publishMessageCreated` (`903-909`). A duplicate receives a duplicate ACK and is not published. The current test confirms accepted observation ACKs and publishes only newly inserted facts (`apps/server/test/onetalk-websocket.test.ts:732-781`).
- `observeMessage` currently performs a second, Server-local generic validation/sanitization pass. It requires `contentType`, accepts arbitrary JSON content, removes object keys matching a sensitive-key regex, and stores `rawMessage.text` (`apps/server/src/onetalk/service.ts:29-104`, `171-245`). This removes some top-level/object-key secrets but cannot safely decode or remove `custom.data` containing Base64/UTF-8 JSON, and it is the wrong owner once MAIN has produced normalized content. The `sanitizeJsonValue` path should be removed for message business content; the Server should validate the shared normalized contract and compose source context/database operations.
- `insertMessage` wraps the conversation existence check, idempotent insert, message-count update, and returned-row lookup in one database transaction (`apps/server/src/onetalk/repository.ts:306-365`). The inserted row is returned from PostgreSQL via `RETURNING` and mapped by `toMessage` (`apps/server/src/onetalk/repository.ts:37-52`, `328-345`). Duplicate handling updates only `last_observed_at` and returns the existing row (`349-364`); it deliberately does not overwrite a prior content fact. This makes the migration reset necessary: re-sync cannot repair an old raw row through duplicate ingestion.
- The commit guard is checked before/after each relevant await and mutation in the transaction (`apps/server/src/onetalk/repository.ts:314-364`). A valid commit is therefore established before the handler sends ACK. The publish is a later external WebSocket side effect; publish authorization/socket failures are handled by the registry and do not roll back the already committed database row (`apps/server/src/websocket/registry.ts:289-357`). This is the invariant to preserve.
### Current HTTP history and raw Server-side decoder
- HTTP history is routed through `GET /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/messages` (`apps/server/src/http/onetalk.ts:19-24`, `396-468`). It authorizes the Mind scope, validates time window/limit/opaque cursor, calls the read service, and returns `result.messages` in the `BrightHistoryResponse` (`apps/server/src/http/onetalk.ts:95-103`, `434-461`). This route itself does not parse OneTalk content.
- The real read path selects `onetalk_message` joined to the direct conversation, applies the as-of snapshot and keyset window, and maps the database row to a read row (`apps/server/src/onetalk/read-repository.ts:117-129`, `179-224`). The read service captures one `asOf` date, reads the conversation, reads messages, then maps every row through `projectCenterMessage` (`apps/server/src/onetalk/read-service.ts:170-237`).
- `apps/server/src/onetalk/read-projection.ts:3-150` is a second Server-side OneTalk decoder. It imports `Buffer`/`isUtf8`, parses Base64 → UTF-8 → JSON, recognizes `contentType=1/101`, `custom.type=7/10010`, and derives media URLs from raw fields. `projectCenterMessage` then returns legacy `CenterMessage` variants (`text`, `img`, `attachment`, `unknown`) (`apps/server/src/onetalk/read-projection.ts:171-230`). Its URL policy is weaker than the task contract: it accepts arbitrary HTTP(S) hosts and uses a fallback chain that can use `thumbnailUrl` as an attachment URL (`apps/server/src/onetalk/read-projection.ts:68-115`).
- The existing read tests intentionally lock in this raw projection and fallback behavior: image/attachment Base64 fixtures, unknown fallback, and thumbnail fallback are asserted in `apps/server/test/onetalk-read-domain.test.ts:365-567`. These tests must be replaced with normalized-content fixtures; retaining them would preserve the prohibited second decoder and false URL semantics.
### Current `message.created` path and parity result
- The registry type and implementation publish `OneTalkMessage`, not the public read model (`apps/server/src/websocket/registry.ts:110-143`, `489-510`). The contract constructor likewise accepts `OneTalkMessage` and places it directly into `payload.message` (`apps/onetalk-contract/src/model.ts:452-458`, `685-697`). Therefore a live message currently includes numeric `readStatus`, `messageStatus`, `unreadCount`, and legacy `contentType`/`text`.
- HTTP history returns the separate local `CenterMessage` union from `read-projection.ts` (`apps/server/src/onetalk/read-model.ts:28-45`, `165-176`). It converts numeric read status to `"read"`/`"unread"` and exposes a different content shape. Live and HTTP messages cannot be deep-equal today, even when they refer to the same database message. Existing WS tests assert the internal message unchanged (`apps/server/test/onetalk-websocket.test.ts:610-668`), while HTTP tests assert legacy `contentType: "text"` (`apps/server/test/onetalk-http.test.ts:70-80`, `256-307`).
- The database fact relationship is still sound for the accepted observation path: HTTP later reads the persisted row, while `message.created` receives the `RETURNING` row mapped from the same insert transaction. The missing piece is a shared public projection and normalized contract, not a second database write. For v3, `OneTalkCenterMessage` should be the only Mind-facing message type, with the same normalized `content` in history and live; only the outer envelope/request metadata may differ.
- Send confirmation has the same commit fence: `send.confirmation` is processed through `observeMessage`, accepted facts are published before the final `send.result`, and duplicate confirmations are returned only when the stored sent fact exactly matches (`apps/server/src/websocket/handler.ts:584-616`; `apps/server/src/websocket/registry.ts:696-772`). Media sending is out of scope, but the normalized text send-confirmation path must not reintroduce top-level `text`/`contentType`.
### Protocol/admission coupling that must be updated with v3
- The contract decoder already fail-closes any protocol version other than its constant (`apps/onetalk-contract/src/decoder.ts:464-486`). Once the constant becomes 3, v2 business frames will be rejected. However, Server admission has several independent hard-coded `2` checks: `cutover-policy.ts:10-14`, `36-41`; WebSocket route admission `apps/server/src/websocket/index.ts:55-73`; handler policy checks `apps/server/src/websocket/handler.ts:224-233`, `546-581`, `967-972`; registry policy checks `apps/server/src/websocket/registry.ts:224-228`; and HTTP read admission `apps/server/src/http/onetalk.ts:278-313`. Updating only the contract constant would make the application reject v3 at admission.
- The existing cutover tests assert Bright v2 and version 2 (`apps/server/test/mind-authorization.test.ts:359-369`). The implementation must either intentionally retain the `bright-v2` mode label while changing its accepted protocol to 3, or rename the mode consistently; it must not leave a split source of truth. The acceptance probe is a v2 frame hard rejection and a v3 frame admission test through both `/ws/plugin` and `/ws/mind`.
### `/harness` current renderer and test gap
- `/harness` is an inline HTML page and is the only Mind-facing UI in this repository; it has no production UI framework (`apps/server/src/http/harness.ts:1-9`; `.trellis/spec/server/frontend/index.md`). It fetches conversation/history over HTTP and connects to `/ws/mind` after history load (`apps/server/src/http/harness.ts:229-261`, `520-563`).
- Its HTTP guard is legacy `CenterMessage`: exact keys include `contentType`, and branches accept `text`, `img`, `attachment`, or `unknown` (`apps/server/src/http/harness.ts:149-190`). The live guard separately expects internal numeric `contentType`, `messageStatus`, and `unreadCount`, explicitly documenting that live remains on the old protocol (`apps/server/src/http/harness.ts:159-169`). This is direct evidence of history/live shape drift.
- History and live do share one in-memory map and identity key `(current account, conversationId, messageId)`: `addMessage` ignores duplicates and `renderMessages` is called for both inputs (`apps/server/src/http/harness.ts:263-299`, `395-415`, `546-555`). The renderer currently emits only direction/time and escaped JSON in `<pre>`; it does not render `content.kind`, `<img>`, file metadata, URL state, or link failures (`267-290`). The shared map is the correct locus to retain for normalized media rendering.
- Current harness tests cover request-generation/scope races and static old-contract keywords, but no media rendering or history/live deep-equality probe exists (`apps/server/test/harness-runtime.test.ts:359-448`; `apps/server/test/onetalk-http.test.ts:585-626`). The HTTP static test itself expects `isCenterMessage` and forbids `decodeOneTalk`, which will need to be updated to the shared normalized shape rather than a new local raw decoder.
### Verified local checks
- `pnpm --filter @trade-message-center/server db:check` passed: Drizzle reported the current schema/migration set as consistent. This does not execute SQL against PostgreSQL and cannot verify the future 0005 deletes, dropped columns, or CHECK constraint.
- Read-only focused execution of `test/onetalk-read-domain.test.ts`, `test/onetalk-websocket.test.ts`, and `test/onetalk-http.test.ts` passed 48/48. This proves the current v2/raw baseline is internally consistent, including the legacy projection; it is not evidence that the media task contract is implemented.
- Direct execution of `test/onetalk-postgres.integration.test.ts` and `test/onetalk-read-postgres.integration.test.ts` produced 3 skips because `TEST_DATABASE_URL` is absent. No live migration, persistence, profile reset, direct-fact rebuild, or SQL read parity was verified.
## Required edits and ordering
1. Stabilize the shared contract first: add normalized content v1 and `OneTalkCenterMessage`; make v3 `OneTalkMessage`/observed frames reject legacy top-level `text`/`contentType` and raw content; keep exact decoding/URL policy in the shared contract plus the MAIN decoder. This is a prerequisite for Server types and prevents Server/Mind from defining another union.
2. Update all Server v3 admission/version checks listed above in one change with the contract. Add a v2 rejection test at the WebSocket boundary and a v3 handshake/observation admission test.
3. Change `apps/server/src/database/schema/onetalk.ts` and generate migration 0005 from the post-0004 schema. The migration must perform the four OneTalk table `DELETE`s before dropping `text`/`content_type`, then add the light JSONB object/version/kind CHECK. Regenerate the 0005 snapshot and append exactly one journal entry; do not rewrite earlier SQL or delete the migration ledger.
4. Replace Server `service.ts` generic message sanitizer/legacy field checks with shared normalized-content validation. Keep anomaly metadata field-only and URL-free. Preserve `repository.ts` transaction, composite idempotency key, duplicate behavior, commit-guard checks, and returned-row source.
5. Replace `read-model.ts`/`read-repository.ts` row content typing and remove `read-projection.ts` Base64/UTF-8/JSON/custom-type/URL-fallback logic. Use one shared normalized-to-public projection for HTTP and WS; it may map numeric read state to public read state, but must not reinterpret raw OneTalk fields or create an `unknown` raw fallback.
6. Make `message.created` publish `OneTalkCenterMessage` using the same public projection/content as history. Keep the event after DB commit and after ACK, retain no publish for duplicates, and keep the send-confirmation `message.created` before `send.result` ordering. Add a test that feeds one normalized text/image/file fact through persistence and compares HTTP history message with live event message using deep equality.
7. Update `/harness` guards/rendering to consume only normalized content. Keep the existing account/conversation/message map and key; render text safely, image metadata plus real `<img src=previewUrl>` with explicit missing/load-error state, and file metadata plus conditional user-triggered preview/download links with safe new-window attributes. Never parse `custom.data` in the page.
8. Replace old Server fixtures/tests using `contentType`, top-level `text`, and raw Base64 with normalized text/image/file fixtures. Keep negative tests for unknown content version, extra keys, URL policy, `downloadState` consistency, v2 rejection, duplicates, anomaly/no-candidate semantics, and profile/direct fact reconstruction.
## Invariants and Acceptance Probes
### Owners and sources of truth
- Normalized content contract owner: `apps/onetalk-contract`; one exact decoder/guard is shared by extension, Server, and harness.
- Raw OneTalk interpretation owner: MAIN-world extension decoder. Server and `/harness` must not understand `custom.data`, `custom.type`, `msgType`, or `subType`.
- Persisted message content owner: PostgreSQL `onetalk_message.content` JSONB. `text` and `content_type` must not remain as parallel columns or application projections.
- Public Mind message owner: shared `OneTalkCenterMessage` projection. HTTP history and `message.created` must use the same projection and normalized content.
- ACK/publish sequencing owner: authenticated WebSocket handler plus registry. The invariant is `DB commit → message.ack → accepted-only message.created`; transaction rollback occurs before ACK, while publish failure occurs after commit and is observable but cannot roll back the row.
- Direct read owner: `onetalk_conversation.conversation_kind = 'direct'` plus profile join in `read-repository.ts`; profile data remains a separate current-fact table and is not folded into message content.
### Async, mutation, side-effect, and rollback boundaries
- Ingress snapshot: handler captures policy epoch before the operation and creates a connection commit guard (`apps/server/src/websocket/handler.ts:540-546`, `709-715`).
- Database mutation: `repository.insertMessage` performs conversation lookup, insert-on-conflict, message-count update, and duplicate read inside one transaction (`apps/server/src/onetalk/repository.ts:314-365`). Every await/mutation boundary is guarded.
- ACK side effect: handler sends `message.ack` only after `observeMessage` has returned from the committed transaction (`apps/server/src/websocket/handler.ts:865-901`). A failed ACK send closes the plugin connection and does not publish.
- Publish side effect: accepted facts are published after ACK; registry reauthorizes each Mind connection and sends the event asynchronously (`apps/server/src/websocket/registry.ts:289-357`, `489-510`). There is no rollback of the database row at this boundary.
- HTTP read snapshot: `read-service` captures `asOf` and passes it to conversation and message queries; no write occurs (`apps/server/src/onetalk/read-service.ts:190-223`). The current live event does not re-query the DB, but uses the row returned by the insert transaction; parity therefore requires a shared public projection, not a second raw parser.
- Migration irreversible boundary: all four deletes and two column drops are schema/data destruction. The only safe proof is an isolated database seeded with all four OneTalk tables plus a non-OneTalk sentinel, then migration/readback assertions. Do not use `db:reset` or any manual production deletion for this task.
- Harness mutation/side effects: fetches and WebSocket callbacks mutate the in-memory conversation/message maps; image load/error and user link clicks are browser side effects. No automatic file download is allowed (`apps/server/src/http/harness.ts:520-605`).
### Deterministic test/mutation matrix
| Probe | Expected result |
| --- | --- |
| v3 normalized text/image/file accepted at shared decoder, v2 frame sent to Server | v3 accepted; v2 returns protocol-upgrade/invalid boundary and does not reach service |
| Missing/unknown content version, extra key, invalid URL/downloadState | rejected before DB insert; anomaly contains code/shape only, no raw content or full URL |
| Insert normalized text/image/file, then duplicate same composite key with different content | first insert accepted; duplicate returns existing fact and does not overwrite content or publish |
| Accepted `message.observed` with mocked event capture | database insert completes, ACK is emitted, then one `message.created`; duplicate emits no event |
| Same DB row through HTTP history and event projection | `history.messages[i]` deep-equals `message.created.payload.message`; neither contains top-level `text`, `contentType`, `custom.data`, `messageStatus`, or `unreadCount` |
| Apply 0005 to isolated v2 schema seeded in message/anomaly/profile/conversation | all four OneTalk tables empty; old columns absent; JSONB CHECK present; migration ledger and non-OneTalk sentinel intact |
| After cleanup, v3 profile observation then direct discovery | profile list/detail joins and direct filtering work; message count/sync/anchor start clean |
| Harness normalized text/image/file, null/failed image, missing download URL, failed link | correct kind card; explicit status retained with metadata; no blank message; download only on user click |
| Static production grep | no Server production `custom.data`, `Buffer`/`isUtf8` raw media parser, `contentType`/top-level `text` message fields, thumbnail download fallback, or private harness media decoder |
## External references, versions, and evidence boundaries
- Repository-local reference: `.trellis/tasks/09-02-onetalk-media-message-sync/design.md:337-398` defines the normalized Server boundary, exact 0005 migration actions, and HTTP/WS parity contract; `prd.md:53-108,120-133` defines the acceptance and out-of-scope boundaries.
- Verified package/runtime declarations: Fastify `^5.12.1`, Drizzle ORM `^0.45.2`, Drizzle Kit `^0.31.10`, `postgres ^3.4.9`, Node engine `>=22.22.2 <23` (`apps/server/package.json:1-58`, `package.json:25-46`). No external web documentation was needed for this source audit.
- PostgreSQL gap: `TEST_DATABASE_URL` is absent in this environment. The integration suite explicitly skips all 3 PostgreSQL tests; no real 0005 migration or content CHECK/readback has been executed.
- Mind gap: `127.0.0.1:7878` was unavailable during this run, and no production Mind authorization/session or independent `trade-mind` runtime was exercised. The repository intentionally treats `/harness` as the only local Mind-facing page; the independent `trade-mind` repository is out of scope.
- Chromium gap: `127.0.0.1:9222/json/version` was unavailable during this run. Existing task-local runtime research contains raw OneTalk history/SDK sample evidence for JPEG, ZIP, and PDF, but there is no current-turn end-to-end proof of v3 MAIN normalization → IDB → Server PostgreSQL → HTTP/live/harness, and no verified live media push sample.
## Caveats / Not Found
- No Server production path currently performs normalized media decoding; the only Server raw decoder is `read-projection.ts`, and it is read-time fallback logic rather than a persistence validator.
- No existing migration test asserts destructive cleanup scope, dropped-column absence, or JSONB CHECK semantics. `db:check` is structural only.
- No existing test compares an HTTP history message to a `message.created` message from the same persisted row. Current tests separately assert legacy CenterMessage and internal OneTalkMessage shapes.
- No current harness test exercises actual DOM image load/error events, safe file links, link-open failures, or user-click-only downloads.
- `cutover-policy.ts` calls the route mode `bright-v2` and hard-codes protocol 2. Whether to rename this mode is an architecture/versioning decision; regardless of naming, every admission path must accept exactly the same v3 constant.
- The repository does not contain the independent Mind production UI or its database. Claims about production Mind rendering, session cookies, media URL availability across origins, and long-term URL validity require external runtime evidence.
@@ -3,7 +3,7 @@
"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",
"status": "in_progress",
"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,
@@ -12,7 +12,7 @@
"assignee": "ybf",
"createdAt": "2026-09-02",
"completedAt": null,
"branch": null,
"branch": "09-02-onetalk-media-message-sync",
"base_branch": "main",
"worktree_path": null,
"commit": null,
@@ -23,4 +23,4 @@
"relatedFiles": [],
"notes": "",
"meta": {}
}
}
@@ -0,0 +1,49 @@
# Work-package map
Task: `.trellis/tasks/09-02-onetalk-media-message-sync`
Status at initialization: `in_progress`
This is a large cross-layer change. The map is recorded before any application
code implementation dispatch. There is one active application-code writer at a
time; research and review workers may run in parallel only when their scopes are
disjoint.
| Package | State | Owner | Exact write scope | Inputs | Output artifact | Acceptance checks | Dependencies | Unblocks |
| --- | --- | --- | --- | --- | --- | --- | --- | --- |
| R-CONTRACT | accepted | trellis-implement → trellis-check | `apps/onetalk-contract/src/**`, `apps/onetalk-contract/test/**` | PRD/design; `research/contract-extension-current.md`; R-EXT finding MEDIA-EXT-003 | direct-message participant guard used by all v3 message boundaries | exactly two unique participants and sender membership; existing content/v3/raw tests remain green | Goodall repair + Pascal revalidation accepted | R-EXT |
| R-EXT | review_pending | trellis-implement → trellis-check | `apps/chrome-extension/src/onetalk/**`, `apps/chrome-extension/test/**` | accepted R-CONTRACT; `research/extension-runtime-current.md`; runtime and media research; MEDIA-EXT-003 | MAIN identity-before-media validation and normalized runtime | invalid identity is safe diagnostic/no candidate; media anomalies retain correct classification; full extension tests | R-CONTRACT accepted | R-SERVER, R-MIND |
| R-SERVER | ready | trellis-implement | `apps/server/src/database/**`, `apps/server/src/onetalk/**`, WS ingress/commit tests and migration artifacts | accepted R-CONTRACT; `research/server-media-current.md`; server/profile integration research | normalized JSONB persistence, commit→ACK→publish parity, next migration | content CHECK; old columns removed; all owned OneTalk facts cleared; duplicate/no publish; server tests/db-check | R-CONTRACT; may consume R-EXT contract shape only | R-MIND |
| R-MIND | blocked | trellis-implement | `apps/server/src/onetalk/read-*.ts`, `apps/server/src/http/onetalk.ts`, `apps/server/src/websocket/**`, `apps/server/src/http/harness.ts`, related tests | accepted R-CONTRACT/R-SERVER; read-model research | one `OneTalkCenterMessage` for history and `message.created`, media harness rendering | deep-equal history/live; no raw decoder/fallback; safe image/file states; no media send UI | R-SERVER; R-EXT contract shape only | DOCS |
| DOCS | blocked | trellis-implement | task-approved OneTalk docs/spec updates only | final accepted implementation and review findings | runtime/bridge/durable-sync/server/Mind contract docs aligned | no stale raw/v2/OSS claims; docs diff scoped and checked | R-EXT, R-SERVER, R-MIND | CHECK |
| CHECK | blocked | trellis-check | test files only if a regression test is necessary; no production edits | final diff, all package reports, finding ledger | independent review report with stable finding IDs | spec/data-flow/security review; targeted validations; no open `blocking_local` findings | R-CONTRACT, R-EXT, R-SERVER, R-MIND, DOCS |
State transitions are updated by the main agent after terminal worker reports and
independent review evidence, not from progress messages alone.
## Research worker lifecycle
| Worker | Role/scope | State | Report | Evidence status |
| --- | --- | --- | --- | --- |
| Turing `01a066f6-8e18-7000-a758-b450888648d1` | shared contract and consumer coupling | completed → closed | `research/contract-extension-current.md` | current v2/raw baseline verified; PostgreSQL/live media/URL runtime gaps explicit |
| Einstein `01a066f6-9789-7171-a470-f20d9ca3d085` | extension MAIN/bridge/SW/IDB runtime | completed → closed | `research/extension-runtime-current.md` | v5 raw propagation and v6 reset boundary verified; live media gap explicit |
| Raman `01a066f6-971e-7b13-a2b0-5822711d00a7` | Server persistence/read/event/harness | completed → closed | `research/server-media-current.md` | `db:check` and 48/48 focused baseline tests passed; PostgreSQL/Mind/CDP/live gaps explicit |
The first implementation round was `R-CONTRACT`. Goodall
`01a066ff-99e0-7721-bafc-c5715bd7ab6c` completed repair round 1 with
`context_usage_percent: unknown`; context-based rebuild routing was skipped.
Pascal `01a0670b-097a-7d52-86aa-2554dd934f10` independently revalidated the
repair and accepted R-CONTRACT. The next active writer is R-EXT; R-SERVER is
ready but remains queued behind the single-writer sequence, and R-MIND remains
blocked on the Server read/event shape.
The next implementation package was R-EXT. Sagan
`01a0671b-97aa-7ac2-88fc-a370c4e0f163` completed its sole implementation round
with `context_usage_percent: unknown`; context-based rebuild routing is skipped
for this package. Zeno's independent review fixed `MEDIA-EXT-002` but kept
`MEDIA-EXT-003` open: the shared direct-message guard and MAIN validation order
both need repair. R-CONTRACT is temporarily reopened for the shared guard;
Goodall completed that serial fix with `context_usage_percent: unknown`, and
Pascal revalidated and accepted the dependency. Sagan completed R-EXT repair
round 2 with `context_usage_percent: unknown`; context-based rebuild routing is
skipped. Zeno revalidation is pending. R-SERVER stays ready but queued while
this cross-package finding converges.
@@ -0,0 +1,270 @@
// 在 MAIN world 将 OneTalk 原始消息内容收敛为共享媒体合同
import {
isOneTalkMessageContent,
ONETALK_CONTENT_VERSION,
ONETALK_MAX_IMAGE_DIMENSION_PX,
ONETALK_MAX_MEDIA_SIZE_BYTES,
type OneTalkMessageContent,
} from "@trade-message-center/onetalk-contract";
import { isRecord } from "../../../lib/guards.ts";
export const ONE_TALK_MEDIA_ANOMALY_CODES = [
"media_invalid_base64",
"media_invalid_utf8",
"media_invalid_json",
"media_payload_too_large",
"media_invalid_schema",
"media_invalid_url",
] as const;
export type OneTalkMediaAnomalyCode = (typeof ONE_TALK_MEDIA_ANOMALY_CODES)[number];
export type OneTalkMediaKind = "image" | "file";
export type OneTalkRawContentDecodeResult =
| { status: "decoded"; content: OneTalkMessageContent }
| { status: "ignored" }
| { status: "unsupported_skipped" }
| { status: "anomaly"; code: OneTalkMediaAnomalyCode; mediaKind: OneTalkMediaKind };
const MAX_ENCODED_MEDIA_PAYLOAD_BYTES = 512 * 1024;
const MAX_MEDIA_URL_LENGTH = 8 * 1024;
const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm";
const THUMBNAIL_URL_PATH = "/file/videoThumb.htm";
const ALLOWED_QUERY_KEYS = new Set([
"appkey",
"fileAction",
"id",
"parentId",
"scene",
"secOperateAliId",
]);
const anomaly = (
code: OneTalkMediaAnomalyCode,
mediaKind: OneTalkMediaKind,
): OneTalkRawContentDecodeResult => ({ status: "anomaly", code, mediaKind });
const isBoundedInteger = (value: unknown, maximum: number): value is number =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= maximum;
const nullableString = (value: unknown): string | null | undefined => {
if (value === undefined || value === "") return null;
return typeof value === "string" ? value : undefined;
};
const decimalSize = (value: unknown): number | null => {
if (typeof value !== "string" || !/^(?:0|[1-9]\d*)$/u.test(value)) return null;
const parsed = Number(value);
return Number.isSafeInteger(parsed) ? parsed : null;
};
const isStrictBase64 = (value: string): boolean =>
value.length > 0 &&
value.length % 4 === 0 &&
/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/u.test(value);
const decodeMediaPayload = (
value: unknown,
mediaKind: OneTalkMediaKind,
):
| { ok: true; payload: Record<string, unknown> }
| { ok: false; result: OneTalkRawContentDecodeResult } => {
if (typeof value !== "string" || !isStrictBase64(value)) {
return { ok: false, result: anomaly("media_invalid_base64", mediaKind) };
}
if (value.length > MAX_ENCODED_MEDIA_PAYLOAD_BYTES) {
return { ok: false, result: anomaly("media_payload_too_large", mediaKind) };
}
let decoded: Uint8Array;
try {
const binary = atob(value);
decoded = Uint8Array.from(binary, (character) => character.charCodeAt(0));
} catch {
return { ok: false, result: anomaly("media_invalid_base64", mediaKind) };
}
let text: string;
try {
text = new TextDecoder("utf-8", { fatal: true }).decode(decoded);
} catch {
return { ok: false, result: anomaly("media_invalid_utf8", mediaKind) };
}
try {
const parsed: unknown = JSON.parse(text);
return isRecord(parsed)
? { ok: true, payload: parsed }
: { ok: false, result: anomaly("media_invalid_schema", mediaKind) };
} catch {
return { ok: false, result: anomaly("media_invalid_json", mediaKind) };
}
};
const isAllowedUrl = (
value: string,
expectedPath: string,
allowedActions: readonly string[] | null,
): boolean => {
if (
value.length === 0 ||
value.length > MAX_MEDIA_URL_LENGTH ||
value.trim() !== value ||
/\s/u.test(value)
) {
return false;
}
try {
const url = new URL(value);
if (
url.protocol !== "https:" ||
url.hostname !== "clouddisk.alibaba.com" ||
url.port.length > 0 ||
url.username.length > 0 ||
url.password.length > 0 ||
url.hash.length > 0 ||
url.pathname !== expectedPath
) {
return false;
}
const seen = new Set<string>();
for (const [key, queryValue] of url.searchParams) {
if (!ALLOWED_QUERY_KEYS.has(key) || seen.has(key) || queryValue.length === 0) {
return false;
}
seen.add(key);
}
const action = url.searchParams.get("fileAction");
return allowedActions === null
? action === null || ["imagePreview", "download", "officePreview"].includes(action)
: action !== null && allowedActions.includes(action);
} catch {
return false;
}
};
const optionalUrl = (
value: unknown,
expectedPath: string,
allowedActions: readonly string[] | null,
mediaKind: OneTalkMediaKind,
): string | null | OneTalkRawContentDecodeResult => {
if (value === undefined || value === null || value === "") return null;
if (typeof value !== "string" || !isAllowedUrl(value, expectedPath, allowedActions)) {
return anomaly("media_invalid_url", mediaKind);
}
return value;
};
const normalizeText = (content: Record<string, unknown>): OneTalkRawContentDecodeResult => {
const text = isRecord(content.text) ? content.text.content : undefined;
const normalized = { version: ONETALK_CONTENT_VERSION, kind: "text" as const, text };
return isOneTalkMessageContent(normalized)
? { status: "decoded", content: normalized }
: { status: "ignored" };
};
const normalizeImage = (payload: Record<string, unknown>): OneTalkRawContentDecodeResult => {
if (
typeof payload.size === "number" &&
(!Number.isSafeInteger(payload.size) || payload.size > ONETALK_MAX_MEDIA_SIZE_BYTES)
) {
return anomaly("media_payload_too_large", "image");
}
const previewUrl = optionalUrl(payload.url, REDIRECT_URL_PATH, ["imagePreview"], "image");
if (previewUrl !== null && typeof previewUrl === "object") return previewUrl;
if (payload.isOriginal !== 0 && payload.isOriginal !== 1) {
return anomaly("media_invalid_schema", "image");
}
const normalized = {
version: ONETALK_CONTENT_VERSION,
kind: "image" as const,
fileId: payload.fileId,
extension:
typeof payload.suffix === "string" ? payload.suffix.toLowerCase() : payload.suffix,
sizeBytes: payload.size,
width: payload.width,
height: payload.height,
isOriginal: payload.isOriginal === 1,
md5: nullableString(payload.md5),
previewUrl,
urlScope: "onetalk_session" as const,
};
if (
!isBoundedInteger(payload.size, ONETALK_MAX_MEDIA_SIZE_BYTES) ||
!isBoundedInteger(payload.width, ONETALK_MAX_IMAGE_DIMENSION_PX) ||
!isBoundedInteger(payload.height, ONETALK_MAX_IMAGE_DIMENSION_PX)
) {
return anomaly("media_invalid_schema", "image");
}
return isOneTalkMessageContent(normalized)
? { status: "decoded", content: normalized }
: anomaly("media_invalid_schema", "image");
};
const normalizeFile = (payload: Record<string, unknown>): OneTalkRawContentDecodeResult => {
if (payload.cardType !== 12) return { status: "unsupported_skipped" };
if (!isRecord(payload.params)) return anomaly("media_invalid_schema", "file");
const params = payload.params;
const sizeBytes = decimalSize(params.size);
if (sizeBytes === null) return anomaly("media_invalid_schema", "file");
if (sizeBytes > ONETALK_MAX_MEDIA_SIZE_BYTES) {
return anomaly("media_payload_too_large", "file");
}
const sourceUrl = optionalUrl(
params.url,
REDIRECT_URL_PATH,
["download", "officePreview"],
"file",
);
if (sourceUrl !== null && typeof sourceUrl === "object") return sourceUrl;
const thumbnailUrl = optionalUrl(params.thumbnailUrl, THUMBNAIL_URL_PATH, null, "file");
if (thumbnailUrl !== null && typeof thumbnailUrl === "object") return thumbnailUrl;
const explicitDownload = optionalUrl(
params.downloadUrl,
REDIRECT_URL_PATH,
["download"],
"file",
);
if (explicitDownload !== null && typeof explicitDownload === "object") return explicitDownload;
const sourceAction =
typeof sourceUrl === "string" ? new URL(sourceUrl).searchParams.get("fileAction") : null;
const downloadUrl = explicitDownload ?? (sourceAction === "download" ? sourceUrl : null);
const previewUrl = sourceAction === "officePreview" ? sourceUrl : null;
const normalized = {
version: ONETALK_CONTENT_VERSION,
kind: "file" as const,
fileId: params.id,
parentId: params.parentId,
fileName: params.name,
extension:
typeof params.extensionType === "string"
? params.extensionType.toLowerCase()
: params.extensionType,
sizeBytes,
md5: nullableString(params.md5),
previewUrl,
thumbnailUrl,
downloadUrl,
downloadState: downloadUrl === null ? ("not_provided" as const) : ("available" as const),
urlScope: "onetalk_session" as const,
};
return isOneTalkMessageContent(normalized)
? { status: "decoded", content: normalized }
: anomaly("media_invalid_schema", "file");
};
/** 解码临时存在于 MAIN world 的 OneTalk raw content;返回值永不携带 raw payload。 */
export const decodeOneTalkRawContent = (value: unknown): OneTalkRawContentDecodeResult => {
if (!isRecord(value) || typeof value.contentType !== "number") return { status: "ignored" };
if (value.contentType === 1) return normalizeText(value);
if (value.contentType !== 101 || !isRecord(value.custom)) return { status: "ignored" };
if (value.custom.type === 7) {
const decoded = decodeMediaPayload(value.custom.data, "image");
return decoded.ok ? normalizeImage(decoded.payload) : decoded.result;
}
if (value.custom.type === 10010) {
const decoded = decodeMediaPayload(value.custom.data, "file");
return decoded.ok ? normalizeFile(decoded.payload) : decoded.result;
}
return { status: "ignored" };
};
@@ -7,15 +7,15 @@ import {
conversationParticipants,
participantIdsFromMessage,
observedMessage,
type ObservedOneTalkMessage,
type OneTalkMessageObservationResult,
} from "./model.ts";
/** 将历史消息响应条目转换为可观察消息。 */
export const parseHistoryMessages = (
items: unknown[],
pageWindow: OneTalkPageWindow,
): ObservedOneTalkMessage[] => {
return items.flatMap((item) => {
): OneTalkMessageObservationResult[] => {
return items.flatMap((item): OneTalkMessageObservationResult[] => {
if (!isRecord(item) || !isRecord(item.message)) return [];
const participantIds =
conversationParticipants(item.message.cid) ?? participantIdsFromMessage(item.message);
@@ -29,6 +29,6 @@ export const parseHistoryMessages = (
"history",
selfParticipant,
);
return message ? [message] : [];
return [message];
});
};
@@ -4,27 +4,31 @@ import { isRecord } from "../../../lib/guards.ts";
import { parseHistoryMessages } from "./history.ts";
import { parseNewMessages } from "./new.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { ObservedOneTalkMessage } from "./model.ts";
import { createParsedMessageBatch, type OneTalkParsedMessageBatch } from "./model.ts";
/** 识别响应帧并委派到历史或新消息解析器。 */
export const parseOneTalkMessages = (
pageWindow: OneTalkPageWindow,
data: unknown,
): ObservedOneTalkMessage[] => {
if (typeof data !== "string") return [];
): OneTalkParsedMessageBatch => {
if (typeof data !== "string") return createParsedMessageBatch([]);
let frame: unknown;
try {
frame = JSON.parse(data);
} catch {
return [];
return createParsedMessageBatch([]);
}
if (!isRecord(frame) || frame.code !== 200) return [];
if (!isRecord(frame) || frame.code !== 200) return createParsedMessageBatch([]);
if (isRecord(frame.body) && Array.isArray(frame.body.userMessageModels)) {
return parseHistoryMessages(frame.body.userMessageModels, pageWindow);
return createParsedMessageBatch(
parseHistoryMessages(frame.body.userMessageModels, pageWindow),
);
}
return Array.isArray(frame.body) ? parseNewMessages(frame.body, pageWindow) : [];
return Array.isArray(frame.body)
? createParsedMessageBatch(parseNewMessages(frame.body, pageWindow))
: createParsedMessageBatch([]);
};
@@ -1,80 +1,80 @@
// 定义 OneTalk 页面原始观察消息
// 定义 OneTalk MAIN-world 归一化观察模型和安全诊断
import {
isOneTalkDirectParticipantSet,
isOneTalkMessage,
ONETALK_CONTENT_VERSION,
type OneTalkMessage,
} from "@trade-message-center/onetalk-contract";
import { isRecord } from "../../../lib/guards.ts";
import {
decodeOneTalkRawContent,
type OneTalkMediaAnomalyCode,
type OneTalkMediaKind,
} from "./content-decoder.ts";
export type OneTalkObservedJsonValue =
| null
| boolean
| number
| string
| OneTalkObservedJsonValue[]
| { [key: string]: OneTalkObservedJsonValue };
export interface ObservedOneTalkMessage {
[key: string]: OneTalkObservedJsonValue | undefined;
messageType?: "new" | "history";
conversationId?: string | null;
messageId?: string | null;
sentAt?: number;
sentAtMs?: number;
contentType?: number;
content?: OneTalkObservedJsonValue;
text?: string | null;
senderId?: string | null;
participantIds?: string[];
direction?: "sent" | "received";
readStatus?: number;
messageStatus?: number;
unreadCount?: number;
}
export type OneTalkObservedMessageSink = (batch: ObservedOneTalkMessage[]) => void;
type OneTalkMessageDirection = NonNullable<ObservedOneTalkMessage["direction"]>;
const isFiniteNumber = (value: unknown): value is number => {
return typeof value === "number" && Number.isFinite(value);
export type ObservedOneTalkMessage = OneTalkMessage & {
messageType: "new" | "history";
};
const isParticipantList = (value: unknown): value is string[] => {
return (
Array.isArray(value) &&
value.length >= 2 &&
value.every(
(participantId) => typeof participantId === "string" && participantId.length > 0,
)
);
export type OneTalkMediaDiagnostic = {
code: OneTalkMediaAnomalyCode;
mediaKind: OneTalkMediaKind;
count: number;
};
const isCompleteParticipantList = (value: string[] | undefined): value is [string, string] => {
return (
value !== undefined &&
value.length === 2 &&
value.every(
(participantId) => participantId.length > 0 && participantId.trim() === participantId,
) &&
new Set(value).size === 2
);
export type OneTalkObservationDiagnostics = {
unsupportedSkippedCount: number;
invalidObservationCount: number;
anomalies: OneTalkMediaDiagnostic[];
};
export type OneTalkMessageObservationResult =
| { status: "decoded"; message: ObservedOneTalkMessage }
| { status: "ignored" }
| { status: "invalid_observation" }
| { status: "unsupported_skipped" }
| { status: "anomaly"; code: OneTalkMediaAnomalyCode; mediaKind: OneTalkMediaKind };
export type OneTalkParsedMessageBatch = {
messages: ObservedOneTalkMessage[];
diagnostics: OneTalkObservationDiagnostics;
};
export type OneTalkObservedMessageSink = (batch: OneTalkParsedMessageBatch) => void;
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0 && value.trim() === value;
const resolveDirection = (
senderId: string | null | undefined,
participantIds: string[] | undefined,
senderId: unknown,
participantIds: unknown,
selfParticipant: string | null,
): OneTalkMessageDirection | undefined => {
if (!selfParticipant || !isCompleteParticipantList(participantIds)) return undefined;
if (!participantIds.includes(selfParticipant) || !senderId) return undefined;
): "sent" | "received" | null => {
if (
typeof senderId !== "string" ||
!isOneTalkDirectParticipantSet(participantIds, senderId) ||
!selfParticipant ||
!participantIds.includes(selfParticipant)
) {
return null;
}
if (senderId === selfParticipant) return "sent";
return participantIds.includes(senderId) ? "received" : undefined;
return "received";
};
const IDENTITY_VALIDATION_CONTENT = {
version: ONETALK_CONTENT_VERSION,
kind: "text" as const,
text: "identity-validation",
};
export const participantIdsFromValue = (value: unknown): string[] => {
if (!Array.isArray(value)) return [];
return value.flatMap((item) => {
if (typeof item === "string" && item.length > 0) return [item];
if (isRecord(item) && typeof item.uid === "string" && item.uid.length > 0) {
return [item.uid];
}
if (isNonEmptyString(item)) return [item];
if (isRecord(item) && isNonEmptyString(item.uid)) return [item.uid];
return [];
});
};
@@ -82,45 +82,16 @@ export const participantIdsFromValue = (value: unknown): string[] => {
export const participantIdsFromMessage = (message: Record<string, unknown>): string[] => {
const direct = participantIdsFromValue(message.participantIds);
if (direct.length >= 2) return direct;
const participants = participantIdsFromValue(message.participants);
return participants.length >= 2 ? participants : [];
};
const copyOptionalFields = (
message: Record<string, unknown>,
output: ObservedOneTalkMessage,
): void => {
const contentRecord = isRecord(message.content) ? message.content : null;
const senderRecord = isRecord(message.sender) ? message.sender : null;
if (typeof message.cid === "string" && message.cid.length > 0)
output.conversationId = message.cid;
if (typeof message.messageId === "string" && message.messageId.length > 0) {
output.messageId = message.messageId;
}
if (isFiniteNumber(message.createAt)) output.sentAt = message.createAt;
if (contentRecord && isFiniteNumber(contentRecord.contentType)) {
output.contentType = contentRecord.contentType;
}
if (contentRecord) output.content = contentRecord as OneTalkObservedJsonValue;
if (senderRecord && typeof senderRecord.uid === "string" && senderRecord.uid.length > 0) {
output.senderId = senderRecord.uid;
}
if (isFiniteNumber(message.unreadCount)) output.unreadCount = message.unreadCount;
const contentText = contentRecord?.text;
if (isRecord(contentText) && typeof contentText.content === "string") {
output.text = contentText.content;
} else {
output.text = null;
}
return participantIdsFromValue(message.participants);
};
export const conversationParticipants = (value: unknown): string[] | null => {
if (typeof value !== "string") return null;
const match = /^([^-]+)-([^#]+)#[^@]+@(.+)$/.exec(value);
const match = /^([^-]+)-([^#]+)#[^@]+@(.+)$/u.exec(value);
return match ? [`${match[1]}@${match[3]}`, `${match[2]}@${match[3]}`] : null;
};
/** 校验并保留页面观察字段,缺失字段交由同步边界诊断。 */
/** 将一个 raw OneTalk message 转成可跨 MAIN 边界的精确观察结果。 */
export const observedMessage = (
message: Record<string, unknown>,
participantIds: string[],
@@ -128,20 +99,70 @@ export const observedMessage = (
messageStatus: unknown,
messageType: "new" | "history",
selfParticipant: string | null,
): ObservedOneTalkMessage | null => {
const resolvedParticipantIds = isParticipantList(participantIds)
? [...participantIds]
: participantIdsFromMessage(message);
const output: ObservedOneTalkMessage = {
messageType,
...(resolvedParticipantIds.length >= 2 ? { participantIds: resolvedParticipantIds } : {}),
): OneTalkMessageObservationResult => {
const resolvedParticipants =
participantIds.length >= 2 ? participantIds : participantIdsFromMessage(message);
const sender = isRecord(message.sender) ? message.sender.uid : undefined;
const direction = resolveDirection(sender, resolvedParticipants, selfParticipant);
const identity = {
messageId: message.messageId,
conversationId: message.cid,
senderId: sender,
direction,
sentAtMs: message.createAt,
content: IDENTITY_VALIDATION_CONTENT,
participantIds: resolvedParticipants,
readStatus,
messageStatus,
unreadCount: message.unreadCount,
};
copyOptionalFields(message, output);
if (!isOneTalkMessage(identity)) return { status: "invalid_observation" };
if (isFiniteNumber(readStatus)) output.readStatus = readStatus;
if (isFiniteNumber(messageStatus)) output.messageStatus = messageStatus;
const direction = resolveDirection(output.senderId, output.participantIds, selfParticipant);
if (direction) output.direction = direction;
const contentResult = decodeOneTalkRawContent(message.content);
if (contentResult.status !== "decoded") return contentResult;
const normalized = { ...identity, content: contentResult.content };
if (!isOneTalkMessage(normalized)) return { status: "invalid_observation" };
return { status: "decoded", message: { ...normalized, messageType } };
};
return Object.keys(output).length > 1 ? output : null;
/** 合并一个 WebSocket 帧内的安全媒体诊断;不会保留 raw message 或 payload。 */
export const createParsedMessageBatch = (
results: OneTalkMessageObservationResult[],
): OneTalkParsedMessageBatch => {
const messages: ObservedOneTalkMessage[] = [];
let unsupportedSkippedCount = 0;
let invalidObservationCount = 0;
const anomalies = new Map<string, OneTalkMediaDiagnostic>();
for (const result of results) {
if (result.status === "decoded") {
messages.push(result.message);
continue;
}
if (result.status === "unsupported_skipped") {
unsupportedSkippedCount += 1;
continue;
}
if (result.status === "invalid_observation") {
invalidObservationCount += 1;
continue;
}
if (result.status === "anomaly") {
const key = `${result.code}:${result.mediaKind}`;
const current = anomalies.get(key);
anomalies.set(
key,
current
? { ...current, count: current.count + 1 }
: { code: result.code, mediaKind: result.mediaKind, count: 1 },
);
}
}
return {
messages,
diagnostics: {
unsupportedSkippedCount,
invalidObservationCount,
anomalies: [...anomalies.values()],
},
};
};
@@ -3,12 +3,16 @@
import { isRecord } from "../../../lib/guards.ts";
import { readSelfParticipant } from "../page-context.ts";
import type { OneTalkPageWindow } from "../model.ts";
import { observedMessage, participantIdsFromValue, type ObservedOneTalkMessage } from "./model.ts";
import {
observedMessage,
participantIdsFromValue,
type OneTalkMessageObservationResult,
} from "./model.ts";
const parseNewMessage = (
conversation: Record<string, unknown>,
pageWindow: OneTalkPageWindow,
): ObservedOneTalkMessage | null => {
): OneTalkMessageObservationResult | null => {
const lastMessage = conversation.lastMessage;
const singleChatConversation = conversation.singleChatConversation;
if (!isRecord(lastMessage) || !isRecord(singleChatConversation)) return null;
@@ -37,8 +41,8 @@ const parseNewMessage = (
export const parseNewMessages = (
items: unknown[],
pageWindow: OneTalkPageWindow,
): ObservedOneTalkMessage[] => {
const messages: ObservedOneTalkMessage[] = [];
): OneTalkMessageObservationResult[] => {
const messages: OneTalkMessageObservationResult[] = [];
for (const item of items) {
if (!isRecord(item) || !isRecord(item.singleChatUserConversation)) continue;
const message = parseNewMessage(item.singleChatUserConversation, pageWindow);
@@ -39,13 +39,7 @@ const unknownResult = (reason: "send_state_lost" | "send_connection_lost" | "sen
}) satisfies PageCommandResult;
const textOf = (message: ObservedOneTalkMessage): string | null => {
if (typeof message.text === "string") return message.text;
if (isRecord(message.content) && isRecord(message.content.text)) {
return typeof message.content.text.content === "string"
? message.content.text.content
: null;
}
return null;
return message.content.kind === "text" ? message.content.text : null;
};
const candidateIds = (value: unknown): string[] => {
@@ -56,10 +50,7 @@ const candidateIds = (value: unknown): string[] => {
};
const completeSentMessage = (message: ObservedOneTalkMessage): OneTalkMessage | null => {
if (message.sentAtMs !== undefined) return isOneTalkMessage(message) ? message : null;
if (message.sentAt === undefined) return null;
const { sentAt: _sentAt, ...withoutLegacyTimestamp } = message;
const normalized = { ...withoutLegacyTimestamp, sentAtMs: message.sentAt };
const { messageType: _messageType, ...normalized } = message;
return isOneTalkMessage(normalized) ? normalized : null;
};
@@ -72,7 +63,7 @@ const matches = (pending: PendingSend, message: ObservedOneTalkMessage, nowMs: n
pending.candidateMessageIds.has(message.messageId)
);
}
const sentAtMs = message.sentAtMs ?? message.sentAt;
const sentAtMs = message.sentAtMs;
if (
typeof sentAtMs !== "number" ||
sentAtMs < pending.sentAfterMs ||
@@ -44,28 +44,50 @@ export const installOneTalkWebSocketTap = (
): void => {
if (pageWindow[INSTALL_KEY]) return;
pageWindow[INSTALL_KEY] = true;
const reportedDiagnostics = new Set<string>();
observeNewWebSocketMessages(
pageWindow,
(url) => isWebSocketUrlForHost(url, ONETALK_WEBSOCKET_HOST),
(data) => {
try {
if (!isHeartbeatResponse(data)) {
pageWindow.console.log(LOG_PREFIX, data);
}
const messages = parseOneTalkMessages(pageWindow, data);
if (messages.length > 0) {
if (isHeartbeatResponse(data)) return;
const batch = parseOneTalkMessages(pageWindow, data);
if (
batch.messages.length > 0 ||
batch.diagnostics.unsupportedSkippedCount > 0 ||
batch.diagnostics.invalidObservationCount > 0 ||
batch.diagnostics.anomalies.length > 0
) {
try {
sink?.(messages);
sink?.(batch);
} catch {
// A sink failure must not affect page observation.
}
}
for (const message of messages) {
pageWindow.console.log(
`[Trade Message Center][OneTalk ${message.messageType} message]`,
message,
);
for (const diagnostic of batch.diagnostics.anomalies) {
const key = `${diagnostic.code}:${diagnostic.mediaKind}`;
if (reportedDiagnostics.has(key)) continue;
reportedDiagnostics.add(key);
pageWindow.console.log(LOG_PREFIX, {
event: "media_anomaly",
code: diagnostic.code,
mediaKind: diagnostic.mediaKind,
});
}
if (
batch.diagnostics.unsupportedSkippedCount > 0 &&
!reportedDiagnostics.has("unsupported_skipped")
) {
reportedDiagnostics.add("unsupported_skipped");
pageWindow.console.log(LOG_PREFIX, { event: "unsupported_skipped" });
}
if (
batch.diagnostics.invalidObservationCount > 0 &&
!reportedDiagnostics.has("invalid_observation")
) {
reportedDiagnostics.add("invalid_observation");
pageWindow.console.log(LOG_PREFIX, { event: "invalid_observation" });
}
} catch {
// Logging must never affect the page's WebSocket behavior.
@@ -31,7 +31,7 @@ const installOneTalkPageFeatures = (): void => {
);
const observedSink = createOneTalkPageObservedSink(window);
installOneTalkMessageObserver(window, (batch) => {
sendObservation.observe(batch);
sendObservation.observe(batch.messages);
observedSink(batch);
});
installCurrentConversationHistorySync(window);
@@ -104,7 +104,11 @@ export const createOneTalkPageObservedSink = (
return (batch) => {
const origin = pageOrigin(pageWindow);
if (!origin) return;
const message: OneTalkPageObservedMessage = createOneTalkPageObservedMessage(batch);
const message: OneTalkPageObservedMessage = createOneTalkPageObservedMessage(
batch.messages,
undefined,
batch.diagnostics,
);
postPageMessage(pageWindow, origin, message);
};
};
@@ -3,12 +3,17 @@
import { isRecord } from "../../lib/guards.ts";
import {
isOneTalkContactProfile,
isOneTalkMessage,
type OneTalkContactProfile,
} from "@trade-message-center/onetalk-contract";
import type { ObservedOneTalkMessage } from "../main-page/message-observer/model.ts";
import type {
ObservedOneTalkMessage,
OneTalkMediaDiagnostic,
OneTalkObservationDiagnostics,
} from "../main-page/message-observer/model.ts";
export const ONE_TALK_PAGE_BRIDGE_SOURCE = "trade-message-center.onetalk.page-bridge";
export const ONE_TALK_PAGE_BRIDGE_VERSION = 1;
export const ONE_TALK_PAGE_BRIDGE_VERSION = 2;
export const ONE_TALK_PAGE_PORT_NAME = "trade-message-center.onetalk.page";
export type JsonValue =
@@ -38,6 +43,7 @@ export type OneTalkPageObservedMessage = {
type: "onetalk.page.observed";
batch: ObservedOneTalkMessage[];
historyProgress?: OneTalkPageHistoryProgress;
diagnostics?: OneTalkObservationDiagnostics;
};
export type OneTalkPageProfileObservedMessage = {
@@ -99,6 +105,14 @@ const isPlainObject = (value: unknown): value is Record<string, unknown> => {
return prototype === Object.prototype || prototype === null;
};
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
return (
actual.length === expected.length && actual.every((key, index) => key === expected[index])
);
};
const isJsonValue = (value: unknown, ancestors = new WeakSet<object>()): value is JsonValue => {
if (value === null) return true;
if (typeof value === "boolean" || typeof value === "string") return true;
@@ -130,7 +144,25 @@ const isJsonObject = (value: unknown): value is JsonObject => {
const decodeHistoryProgress = (value: unknown): OneTalkPageHistoryProgress | null | undefined => {
if (value === undefined) return undefined;
if (!isPlainObject(value)) return null;
const allowedKeys = [
"conversationId",
"page",
"mode",
"anchorMessageId",
"nextTimeStamp",
"historyComplete",
"anchorFound",
];
if (
!Object.keys(value).every((key) => allowedKeys.includes(key)) ||
![
"conversationId",
"page",
"mode",
"anchorMessageId",
"nextTimeStamp",
"historyComplete",
].every((key) => Object.hasOwn(value, key)) ||
typeof value.conversationId !== "string" ||
value.conversationId.length === 0 ||
typeof value.page !== "number" ||
@@ -200,21 +232,103 @@ const decodeHelloMessage = (value: Record<string, unknown>): OneTalkPageHelloMes
};
const decodeObservedMessage = (value: unknown): ObservedOneTalkMessage | null => {
if (!isPlainObject(value)) return null;
if (!isJsonValue(value)) return null;
if (
!isPlainObject(value) ||
!hasExactKeys(value, [
"messageType",
"messageId",
"conversationId",
"senderId",
"direction",
"sentAtMs",
"content",
"participantIds",
"readStatus",
"messageStatus",
"unreadCount",
]) ||
(value.messageType !== "new" && value.messageType !== "history")
) {
return null;
}
const { messageType, ...message } = value;
if (!isOneTalkMessage(message)) return null;
return {
...message,
participantIds: [...message.participantIds],
content: { ...message.content },
messageType,
};
};
const normalized: ObservedOneTalkMessage = {};
for (const [key, item] of Object.entries(value)) normalized[key] = item;
return normalized;
const decodeDiagnostics = (value: unknown): OneTalkObservationDiagnostics | null | undefined => {
if (value === undefined) return undefined;
if (
!isPlainObject(value) ||
!hasExactKeys(value, ["unsupportedSkippedCount", "invalidObservationCount", "anomalies"])
) {
return null;
}
const unsupportedSkippedCount = value.unsupportedSkippedCount;
const invalidObservationCount = value.invalidObservationCount;
const rawAnomalies = value.anomalies;
if (
typeof unsupportedSkippedCount !== "number" ||
!Number.isSafeInteger(unsupportedSkippedCount) ||
unsupportedSkippedCount < 0 ||
typeof invalidObservationCount !== "number" ||
!Number.isSafeInteger(invalidObservationCount) ||
invalidObservationCount < 0 ||
!Array.isArray(rawAnomalies)
) {
return null;
}
const seen = new Set<string>();
const anomalies: OneTalkMediaDiagnostic[] = [];
for (const anomaly of rawAnomalies) {
if (!isPlainObject(anomaly)) return null;
const code = anomaly.code;
const mediaKind = anomaly.mediaKind;
const count = anomaly.count;
if (
!hasExactKeys(anomaly, ["code", "mediaKind", "count"]) ||
(code !== "media_invalid_base64" &&
code !== "media_invalid_utf8" &&
code !== "media_invalid_json" &&
code !== "media_payload_too_large" &&
code !== "media_invalid_schema" &&
code !== "media_invalid_url") ||
(mediaKind !== "image" && mediaKind !== "file") ||
typeof count !== "number" ||
!Number.isSafeInteger(count) ||
count < 1
) {
return null;
}
const key = `${code}:${mediaKind}`;
if (seen.has(key)) return null;
seen.add(key);
anomalies.push({ code, mediaKind, count });
}
return { unsupportedSkippedCount, invalidObservationCount, anomalies };
};
const decodeObservedMessageEnvelope = (
value: Record<string, unknown>,
): OneTalkPageObservedMessage | null => {
if (!Array.isArray(value.batch)) return null;
const allowedKeys = ["source", "version", "type", "batch", "historyProgress", "diagnostics"];
if (
!Array.isArray(value.batch) ||
!Object.keys(value).every((key) => allowedKeys.includes(key)) ||
!["source", "version", "type", "batch"].every((key) => Object.hasOwn(value, key))
) {
return null;
}
const historyProgress = decodeHistoryProgress(value.historyProgress);
if (historyProgress === null) return null;
const diagnostics = decodeDiagnostics(value.diagnostics);
if (diagnostics === null) return null;
const batch: ObservedOneTalkMessage[] = [];
for (const item of value.batch) {
@@ -229,6 +343,7 @@ const decodeObservedMessageEnvelope = (
type: "onetalk.page.observed",
batch,
...(historyProgress === undefined ? {} : { historyProgress }),
...(diagnostics === undefined ? {} : { diagnostics }),
};
};
@@ -352,6 +467,7 @@ export const createOneTalkPageHelloMessage = (
export const createOneTalkPageObservedMessage = (
batch: ObservedOneTalkMessage[],
historyProgress?: OneTalkPageHistoryProgress,
diagnostics?: OneTalkObservationDiagnostics,
): OneTalkPageObservedMessage => {
return {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
@@ -359,6 +475,7 @@ export const createOneTalkPageObservedMessage = (
type: "onetalk.page.observed",
batch,
...(historyProgress === undefined ? {} : { historyProgress }),
...(diagnostics === undefined ? {} : { diagnostics }),
};
};
@@ -14,7 +14,6 @@ const MESSAGE_FIELDS = [
"direction",
"sentAtMs",
"content",
"contentType",
"participantIds",
"readStatus",
"messageStatus",
@@ -1,8 +1,7 @@
// 持久化 OneTalk 同步账本与页面观察
import {
isOneTalkContactProfile,
type OneTalkJsonValue,
isOneTalkMessage,
type OneTalkContactProfile,
type OneTalkObservedMessage,
type OneTalkObservationSource,
@@ -11,25 +10,15 @@ import {
type OneTalkSyncResult,
} from "@trade-message-center/onetalk-contract";
import type { ObservedOneTalkMessage } from "../main-page/message-observer/model.ts";
export const ONE_TALK_MESSAGE_DATABASE_NAME = "trade-message-center";
export const ONE_TALK_SYNC_DATABASE_VERSION = 5;
export const ONE_TALK_MESSAGE_DATABASE_VERSION = ONE_TALK_SYNC_DATABASE_VERSION;
export const ONE_TALK_SYNC_DATABASE_VERSION = 6;
export const ONE_TALK_MESSAGE_STORE_NAME = "onetalk_messages";
export const ONE_TALK_CHECKPOINT_STORE_NAME = "onetalk_sync_checkpoints";
export const ONE_TALK_CANDIDATE_STORE_NAME = "onetalk_sync_candidates";
export const ONE_TALK_ANOMALY_STORE_NAME = "onetalk_sync_anomalies";
export const ONE_TALK_CONTACT_PROFILE_STORE_NAME = "onetalk_contact_profiles";
export type StoredOneTalkMessage = ObservedOneTalkMessage & {
key: string;
channelAccountId: string;
receivedAt: number;
};
export type StoredOneTalkObservation = {
[key: string]: OneTalkJsonValue | undefined;
export type StoredOneTalkObservation = OneTalkObservedMessage & {
key: string;
channelAccountId: string;
conversationId: string;
@@ -107,10 +96,6 @@ export type OneTalkContactProfileLedgerRecord = {
lastUploadedAt?: number;
};
export type OneTalkMessageStore = {
putBatch: (channelAccountId: string, batch: ObservedOneTalkMessage[]) => Promise<void>;
};
export type PersistObservedBatchInput = {
channelAccountId: string;
conversationId: string;
@@ -125,7 +110,7 @@ export type PersistObservedBatchResult = {
anomalies: OneTalkSyncAnomaly[];
};
export type OneTalkSyncStore = OneTalkMessageStore & {
export type OneTalkSyncStore = {
getCheckpoint: (
channelAccountId: string,
conversationId: string,
@@ -184,10 +169,6 @@ export type OneTalkContactProfileStore = {
}) => Promise<boolean>;
};
const messageKey = (channelAccountId: string, message: ObservedOneTalkMessage): string => {
return JSON.stringify([channelAccountId, message.conversationId, message.messageId]);
};
const syncKey = (channelAccountId: string, conversationId: string): string => {
return JSON.stringify([channelAccountId, conversationId]);
};
@@ -212,64 +193,10 @@ const isFiniteInteger = (value: unknown): value is number => {
return typeof value === "number" && Number.isSafeInteger(value);
};
const isRecord = (value: unknown): value is Record<string, unknown> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
const isJsonValue = (value: unknown, ancestors = new WeakSet<object>()): boolean => {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value !== "object" || ancestors.has(value)) return false;
ancestors.add(value);
try {
if (Array.isArray(value)) return value.every((item) => isJsonValue(item, ancestors));
if (
Object.getPrototypeOf(value) !== Object.prototype &&
Object.getPrototypeOf(value) !== null
) {
return false;
}
return Object.values(value).every((item) => isJsonValue(item, ancestors));
} finally {
ancestors.delete(value);
}
};
const finiteTimestamp = (value: number | undefined, fallback: number): number => {
return value !== undefined && Number.isFinite(value) ? value : fallback;
};
const legacyProfileRecord = (value: unknown): OneTalkContactProfileLedgerRecord | null => {
if (!isRecord(value) || !isNonEmptyString(value.channelAccountId)) return null;
const pending = isRecord(value.pending) ? value.pending : null;
if (!pending || !isOneTalkContactProfile(pending.profile)) return null;
const profile = pending.profile;
return {
key: contactProfileKey(value.channelAccountId, profile.conversationId),
channelAccountId: value.channelAccountId,
conversationId: profile.conversationId,
aliId: profile.aliId,
lastUploadedFingerprint:
typeof value.lastUploadedFingerprint === "string"
? value.lastUploadedFingerprint
: null,
pending: {
profile: { ...profile },
fingerprint: profile.profileFingerprint,
observedAtMs: profile.observedAtMs,
},
updatedAt: finiteTimestamp(
typeof value.updatedAt === "number" ? value.updatedAt : undefined,
profile.observedAtMs,
),
...(typeof value.lastUploadedAt === "number"
? { lastUploadedAt: value.lastUploadedAt }
: {}),
};
};
const observationAnomalyKey = (
channelAccountId: string,
conversationId: string | undefined,
@@ -286,30 +213,6 @@ const observationAnomalyKey = (
]);
};
export const toStoredOneTalkMessage = (
channelAccountId: string,
message: ObservedOneTalkMessage,
receivedAt: number,
): StoredOneTalkMessage => {
const { loginUserId: _loginUserId, ...messageWithoutLegacyField } =
message as ObservedOneTalkMessage & {
loginUserId?: unknown;
};
return {
...messageWithoutLegacyField,
key: messageKey(channelAccountId, message),
channelAccountId,
receivedAt,
};
};
const withoutLegacyLoginUserId = <T extends Record<string, unknown>>(
value: T,
): Omit<T, "loginUserId"> => {
const { loginUserId: _loginUserId, ...rest } = value;
return rest;
};
const toStoredObservation = (
input: PersistObservedBatchInput,
message: OneTalkObservedMessage,
@@ -320,7 +223,9 @@ const toStoredObservation = (
}
return {
...withoutLegacyLoginUserId(message),
...message,
participantIds: [...message.participantIds],
content: { ...message.content },
key: candidateKey(input.channelAccountId, input.conversationId, message.messageId),
channelAccountId: input.channelAccountId,
conversationId: input.conversationId,
@@ -343,77 +248,18 @@ const ensureSyncStores = (database: IDBDatabase): void => {
}
};
const cleanLegacyMessageFields = (transaction: IDBTransaction): void => {
for (const storeName of [ONE_TALK_MESSAGE_STORE_NAME, ONE_TALK_CANDIDATE_STORE_NAME]) {
const store = transaction.objectStore(storeName);
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return;
const record = cursor.value as Record<string, unknown>;
const cleaned = withoutLegacyLoginUserId(record) as Record<string, unknown>;
if (storeName === ONE_TALK_CANDIDATE_STORE_NAME && record.message !== undefined) {
cleaned.message = withoutLegacyLoginUserId(
record.message as Record<string, unknown>,
);
}
cursor.update(cleaned);
cursor.continue();
};
const clearOneTalkStores = (transaction: IDBTransaction): void => {
for (const storeName of [
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
]) {
transaction.objectStore(storeName).clear();
}
};
const migrateLegacyContactProfileLedger = (transaction: IDBTransaction): void => {
const store = transaction.objectStore(ONE_TALK_CONTACT_PROFILE_STORE_NAME);
const migratedRecords = new Map<string, OneTalkContactProfileLedgerRecord>();
const request = store.openCursor();
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) return;
const migrated = legacyProfileRecord(cursor.value);
if (migrated) {
const existing = migratedRecords.get(migrated.key);
if (!existing || migrated.pending!.observedAtMs > existing.pending!.observedAtMs) {
migratedRecords.set(migrated.key, migrated);
const legacyKey =
typeof cursor.primaryKey === "string"
? cursor.primaryKey
: isRecord(cursor.value) && typeof cursor.value.key === "string"
? cursor.value.key
: null;
if (legacyKey === migrated.key) cursor.update(migrated);
else store.put(migrated);
}
}
const legacyKey =
typeof cursor.primaryKey === "string"
? cursor.primaryKey
: isRecord(cursor.value) && typeof cursor.value.key === "string"
? cursor.value.key
: null;
if (legacyKey !== migrated?.key) cursor.delete();
cursor.continue();
};
};
const openDatabase = (factory: IDBFactory, version: number): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = factory.open(ONE_TALK_MESSAGE_DATABASE_NAME, version);
request.onupgradeneeded = (event?: IDBVersionChangeEvent) => {
ensureSyncStores(request.result);
if ((event?.oldVersion ?? 0) < 3 && request.transaction) {
cleanLegacyMessageFields(request.transaction);
}
if ((event?.oldVersion ?? 0) < 5 && request.transaction) {
migrateLegacyContactProfileLedger(request.transaction);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error ?? new Error("IndexedDB open failed"));
request.onblocked = () => reject(new Error("IndexedDB open blocked"));
});
};
const openSyncDatabase = (factory: IDBFactory): Promise<IDBDatabase> => {
return new Promise((resolve, reject) => {
const request = factory.open(
@@ -422,11 +268,8 @@ const openSyncDatabase = (factory: IDBFactory): Promise<IDBDatabase> => {
);
request.onupgradeneeded = (event?: IDBVersionChangeEvent) => {
ensureSyncStores(request.result);
if ((event?.oldVersion ?? 0) < 3 && request.transaction) {
cleanLegacyMessageFields(request.transaction);
}
if ((event?.oldVersion ?? 0) < 5 && request.transaction) {
migrateLegacyContactProfileLedger(request.transaction);
if ((event?.oldVersion ?? 0) < 6 && request.transaction) {
clearOneTalkStores(request.transaction);
}
};
request.onsuccess = () => resolve(request.result);
@@ -472,23 +315,6 @@ const readOne = async <T>(
return result as T | undefined;
};
const writeBatch = async (
database: IDBDatabase,
channelAccountId: string,
batch: ObservedOneTalkMessage[],
): Promise<void> => {
if (batch.length === 0) return;
const transaction = database.transaction(ONE_TALK_MESSAGE_STORE_NAME, "readwrite");
const completion = transactionResult(transaction);
const store = transaction.objectStore(ONE_TALK_MESSAGE_STORE_NAME);
const receivedAt = Date.now();
for (const message of batch) {
store.put(toStoredOneTalkMessage(channelAccountId, message, receivedAt));
}
await completion;
};
const validObservationFields = (
input: PersistObservedBatchInput,
message: OneTalkObservedMessage,
@@ -504,8 +330,7 @@ const validObservationFields = (
fields.push("direction");
}
if (!isFiniteInteger(message.sentAtMs)) fields.push("sentAtMs");
if (message.content === undefined || !isJsonValue(message.content)) fields.push("content");
if (!isFiniteInteger(message.contentType)) fields.push("contentType");
if (!isOneTalkMessage(message)) fields.push("message");
if (
!Array.isArray(message.participantIds) ||
!message.participantIds.every((participantId) => isNonEmptyString(participantId))
@@ -515,9 +340,6 @@ const validObservationFields = (
if (!isFiniteInteger(message.readStatus)) fields.push("readStatus");
if (!isFiniteInteger(message.messageStatus)) fields.push("messageStatus");
if (!isFiniteInteger(message.unreadCount)) fields.push("unreadCount");
if (message.text !== undefined && message.text !== null && typeof message.text !== "string") {
fields.push("text");
}
return fields;
};
@@ -571,7 +393,11 @@ const createCandidate = (
channelAccountId: input.channelAccountId,
conversationId: input.conversationId,
messageId: message.messageId,
message: withoutLegacyLoginUserId(message),
message: {
...message,
participantIds: [...message.participantIds],
content: { ...message.content },
},
observationSource: input.observationSource,
status: input.mode === "incremental" ? "awaiting_anchor" : "pending_ack",
firstObservedAt: observedAt,
@@ -657,28 +483,6 @@ const mergeAnomaly = (
};
};
/** 创建兼容旧消息表的 OneTalk IndexedDB 存储。 */
export const createOneTalkMessageStore = (factory: IDBFactory = indexedDB): OneTalkMessageStore => {
let databasePromise: Promise<IDBDatabase> | null = null;
const getDatabase = (): Promise<IDBDatabase> => {
databasePromise ??= openDatabase(factory, ONE_TALK_MESSAGE_DATABASE_VERSION).catch(
(error: unknown) => {
databasePromise = null;
throw error;
},
);
return databasePromise;
};
return {
putBatch: async (channelAccountId, batch) => {
const database = await getDatabase();
await writeBatch(database, channelAccountId, batch);
},
};
};
/** 创建支持断线恢复和逐条确认的 OneTalk 同步账本。 */
export const createOneTalkSyncStore = (
factory: IDBFactory = indexedDB,
@@ -837,10 +641,6 @@ export const createOneTalkSyncStore = (
};
return {
putBatch: async (channelAccountId, batch) => {
const database = await getDatabase();
await writeBatch(database, channelAccountId, batch);
},
getCheckpoint,
listCheckpoints,
putCheckpoint,
@@ -64,18 +64,15 @@ export const nowOr = (now: () => number): number => {
};
export const pageMessageToObserved = (message: ObservedOneTalkMessage): OneTalkObservedMessage => {
const observed: OneTalkObservedMessage = {};
for (const [key, value] of Object.entries(message)) observed[key] = value;
if (isFiniteNumber(message.sentAt) && observed.sentAtMs === undefined) {
observed.sentAtMs = message.sentAt;
}
if (observed.content === undefined && message.text !== undefined) {
observed.content = message.text === null ? null : { text: message.text };
}
if (message.participantIds !== undefined) {
observed.participantIds = [...message.participantIds];
}
return observed;
const { messageType: _messageType, ...observed } = message;
return {
...observed,
participantIds: Array.isArray(observed.participantIds) ? [...observed.participantIds] : [],
content:
typeof observed.content === "object" && observed.content !== null
? { ...observed.content }
: observed.content,
} as OneTalkObservedMessage;
};
export const pageCommandFor = (
@@ -5,7 +5,6 @@ import type {
OneTalkObservationSource,
} from "@trade-message-center/onetalk-contract";
import type { ObservedOneTalkMessage } from "../../main-page/message-observer/model.ts";
import type {
OneTalkPageHistoryProgress,
OneTalkPageObservedMessage,
@@ -142,7 +141,14 @@ export class ObservationPipeline {
channelAccountId: string,
): Promise<OneTalkSyncObservationResult> {
if (this.lifecycle.isDisposed()) return { candidates: [], anomalies: [] };
const converted = message.batch.map(pageMessageToObserved);
const hasPageAnomalies =
message.diagnostics !== undefined &&
(message.diagnostics.invalidObservationCount > 0 ||
message.diagnostics.anomalies.length > 0);
const diagnosticAnomalies = await this.persistPageDiagnostics(
channelAccountId,
message.diagnostics,
);
const groupedByConversation = new Map<
string | undefined,
{
@@ -153,8 +159,9 @@ export class ObservationPipeline {
}>;
}
>();
for (const item of converted) {
const source = item.messageType === "history" ? "history" : "live";
for (const pageMessage of message.batch) {
const source = pageMessage.messageType === "history" ? "history" : "live";
const item = pageMessageToObserved(pageMessage);
const conversationId = isNonEmptyString(item.conversationId)
? item.conversationId
: undefined;
@@ -191,6 +198,7 @@ export class ObservationPipeline {
},
group.conversationId,
[item.message],
hasPageAnomalies,
),
);
}
@@ -212,7 +220,7 @@ export class ObservationPipeline {
}
return {
candidates: results.flatMap((result) => result.candidates),
anomalies: results.flatMap((result) => result.anomalies),
anomalies: [...diagnosticAnomalies, ...results.flatMap((result) => result.anomalies)],
};
}
@@ -239,6 +247,7 @@ export class ObservationPipeline {
input: OneTalkSyncObservationInput,
conversationId: string,
messages: OneTalkObservedMessage[],
hasPageAnomalies = false,
): Promise<OneTalkSyncObservationResult> {
if (this.lifecycle.isDisposed()) return { candidates: [], anomalies: [] };
const existing = await this.checkpoints.getCheckpoint(
@@ -281,7 +290,7 @@ export class ObservationPipeline {
checkpoint,
observedAt,
hasPendingCandidate,
hasAnomalies: result.anomalies.length > 0,
hasAnomalies: hasPageAnomalies || result.anomalies.length > 0,
});
if (checkpoint.mode === "incremental" && checkpoint.anchorState === "found") {
// The ACK coordinator activates candidates after the anchor is known.
@@ -301,13 +310,16 @@ export class ObservationPipeline {
input: OneTalkSyncObservationInput,
conversationId: string,
messages: OneTalkObservedMessage[],
hasPageAnomalies = false,
): Promise<OneTalkSyncObservationResult> {
if (this.lifecycle.isDisposed()) return { candidates: [], anomalies: [] };
const key = scopedKey(input.channelAccountId, conversationId);
const previous =
this.observationWrites.get(key) ??
Promise.resolve({ candidates: [], anomalies: [] } as OneTalkSyncObservationResult);
const current = previous.then(() => this.persistGroup(input, conversationId, messages));
const current = previous.then(() =>
this.persistGroup(input, conversationId, messages, hasPageAnomalies),
);
this.observationWrites.set(key, current);
try {
return await current;
@@ -334,4 +346,77 @@ export class ObservationPipeline {
if (this.observationWrites.get(key) === current) this.observationWrites.delete(key);
}
}
private async persistPageDiagnostics(
channelAccountId: string,
diagnostics: OneTalkPageObservedMessage["diagnostics"],
): Promise<OneTalkSyncObservationResult["anomalies"]> {
if (!diagnostics) return [];
const observedAt = nowOr(this.now);
const anomalies = [
...(diagnostics.unsupportedSkippedCount > 0
? [
{
key: JSON.stringify([
channelAccountId,
null,
"unsupported_skipped",
["card"],
"sync",
]),
channelAccountId,
code: "unsupported_skipped",
observationSource: "sync" as const,
fields: ["card"],
occurrenceCount: diagnostics.unsupportedSkippedCount,
firstObservedAt: observedAt,
lastObservedAt: observedAt,
},
]
: []),
...(diagnostics.invalidObservationCount > 0
? [
{
key: JSON.stringify([
channelAccountId,
null,
"invalid_observation",
["observation"],
"sync",
]),
channelAccountId,
code: "invalid_observation",
observationSource: "sync" as const,
fields: ["observation"],
occurrenceCount: diagnostics.invalidObservationCount,
firstObservedAt: observedAt,
lastObservedAt: observedAt,
},
]
: []),
...diagnostics.anomalies.map((diagnostic) => ({
key: JSON.stringify([
channelAccountId,
null,
diagnostic.code,
[diagnostic.mediaKind],
"sync",
]),
channelAccountId,
code: diagnostic.code,
observationSource: "sync" as const,
fields: [diagnostic.mediaKind],
occurrenceCount: diagnostic.count,
firstObservedAt: observedAt,
lastObservedAt: observedAt,
})),
];
return Promise.all(
anomalies.map(async (anomaly) => {
const persisted = await this.store.recordAnomaly(anomaly);
this.lifecycle.setLastError(persisted.code);
return persisted;
}),
);
}
}
@@ -123,8 +123,7 @@ test("authenticates, receives anchors, uploads observations, and heartbeats", as
senderId: "sender-1",
direction: "received",
sentAtMs: 1_700_000_000_001,
content: { text: "hello" },
contentType: 1,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "login-1"],
readStatus: 0,
messageStatus: 1,
@@ -403,8 +402,7 @@ test("sends a complete confirmed_sent message through the strict confirmation co
senderId: "sender-1",
direction: "sent",
sentAtMs: 1_700_000_000_000,
content: { text: "hello" },
contentType: 1,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "recipient-1"],
readStatus: 0,
messageStatus: 1,
@@ -421,8 +419,7 @@ test("sends a complete confirmed_sent message through the strict confirmation co
senderId: "sender-1",
direction: "sent",
sentAtMs: 1_700_000_000_000,
content: { text: "hello" },
contentType: 1,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "recipient-1"],
readStatus: 0,
messageStatus: 1,
@@ -240,8 +240,7 @@ test("forwards a complete sent page message as confirmed_sent and preserves corr
senderId: "sender-1",
direction: "sent",
sentAtMs: 1_700_000_000_000,
content: { text: "hello" },
contentType: 1,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "recipient-1"],
readStatus: 0,
messageStatus: 1,
@@ -64,6 +64,10 @@ class Store {
this.transaction.remove(this.records, key);
}
clear() {
for (const key of this.records.keys()) this.transaction.remove(this.records, key);
}
openCursor() {
const entries = [...this.records.entries()];
const request = new Request(null, false);
@@ -206,139 +210,22 @@ class Factory {
const tick = () => new Promise((resolve) => setImmediate(resolve));
test("upgrades existing legacy stores to the profile ledger schema", async () => {
test("resets all legacy OneTalk stores when upgrading to v6", async () => {
const factory = new Factory();
factory.database.version = 5;
factory.database
.createObjectStore(ONE_TALK_CONTACT_PROFILE_STORE_NAME)
.records.set("profile-1", {
key: "profile-1",
});
const store = createOneTalkContactProfileStore(factory, () => 100);
assert.equal(await store.getProfile("account-1", "missing"), null);
assert.equal(factory.database.version, 5);
assert.equal(factory.database.version, 6);
assert.equal(factory.database.stores.has(ONE_TALK_CONTACT_PROFILE_STORE_NAME), true);
assert.equal(
factory.database.stores.get("onetalk_messages").records.get("legacy-message").loginUserId,
undefined,
);
const candidate = factory.database.stores
.get("onetalk_sync_candidates")
.records.get("legacy-candidate");
assert.equal(candidate.loginUserId, undefined);
assert.equal(candidate.message.loginUserId, undefined);
});
test("rekeys pending v4 profile records by account and conversation and removes uploaded-only records", async () => {
const factory = new Factory();
factory.database.version = 4;
const legacyStore = factory.database.createObjectStore(ONE_TALK_CONTACT_PROFILE_STORE_NAME);
const pending = profile("v4-pending", "https://cdn.example.test/avatar/pending.jpg");
const pendingOtherConversation = profile(
"v4-pending-other",
"https://cdn.example.test/avatar/pending-other.jpg",
pending.observedAtMs,
"conversation-2",
pending.aliId,
);
legacyStore.records.set("legacy-pending", {
key: "legacy-pending",
channelAccountId: "account-1",
aliId: pending.aliId,
lastUploadedFingerprint: null,
pending: {
profile: pending,
fingerprint: pending.profileFingerprint,
observedAtMs: pending.observedAtMs,
},
updatedAt: pending.observedAtMs,
});
legacyStore.records.set("legacy-uploaded", {
key: "legacy-uploaded",
channelAccountId: "account-1",
aliId: "same-ali-without-conversation",
lastUploadedFingerprint: "old-uploaded",
updatedAt: 1,
});
legacyStore.records.set("legacy-invalid", {
key: "legacy-invalid",
channelAccountId: "account-1",
aliId: "invalid-profile",
pending: { profile: { conversationId: "missing-fields" } },
updatedAt: 1,
});
legacyStore.records.set("legacy-pending-other-conversation", {
key: "legacy-pending-other-conversation",
channelAccountId: "account-1",
aliId: pendingOtherConversation.aliId,
lastUploadedFingerprint: null,
pending: {
profile: pendingOtherConversation,
fingerprint: pendingOtherConversation.profileFingerprint,
observedAtMs: pendingOtherConversation.observedAtMs,
},
updatedAt: pendingOtherConversation.observedAtMs,
});
const store = createOneTalkContactProfileStore(factory, () => 100);
const migrated = await store.getProfile("account-1", pending.conversationId);
assert.equal(factory.database.version, 5);
assert.deepEqual(migrated.pending.profile, pending);
assert.equal(migrated.key, contactProfileKey("account-1", pending.conversationId));
assert.deepEqual(
(await store.getProfile("account-1", pendingOtherConversation.conversationId)).pending
.profile,
pendingOtherConversation,
);
assert.equal(legacyStore.records.has("legacy-pending"), false);
assert.equal(legacyStore.records.has("legacy-uploaded"), false);
assert.equal(legacyStore.records.has("legacy-invalid"), false);
assert.equal(legacyStore.records.has("legacy-pending-other-conversation"), false);
});
test("keeps one newest pending record when multiple legacy rows map to one conversation key", async () => {
const factory = new Factory();
factory.database.version = 4;
const legacyStore = factory.database.createObjectStore(ONE_TALK_CONTACT_PROFILE_STORE_NAME);
const older = profile(
"legacy-older",
"https://cdn.example.test/avatar/older.jpg",
100,
"conversation-1",
"ali-older",
);
const newer = profile(
"legacy-newer",
"https://cdn.example.test/avatar/newer.jpg",
200,
"conversation-1",
"ali-newer",
);
for (const [key, next] of [
["legacy-row-1", older],
["legacy-row-2", newer],
]) {
legacyStore.records.set(key, {
key,
channelAccountId: "account-1",
aliId: next.aliId,
lastUploadedFingerprint: null,
pending: {
profile: next,
fingerprint: next.profileFingerprint,
observedAtMs: next.observedAtMs,
},
updatedAt: next.observedAtMs,
});
for (const storeRecord of factory.database.stores.values()) {
assert.equal(storeRecord.records.size, 0);
}
const store = createOneTalkContactProfileStore(factory, () => 100);
const migrated = await store.getProfile("account-1", "conversation-1");
const pending = await store.listPendingProfiles("account-1");
assert.equal(migrated.pending.profile.profileFingerprint, newer.profileFingerprint);
assert.deepEqual(
pending.map((record) => record.key),
[contactProfileKey("account-1", "conversation-1")],
);
assert.equal(legacyStore.records.has("legacy-row-1"), false);
assert.equal(legacyStore.records.has("legacy-row-2"), false);
});
test("does not return true before the readwrite transaction commits", async () => {
@@ -0,0 +1,308 @@
// 验证 MAIN-world OneTalk 媒体归一化边界
import assert from "node:assert/strict";
import test from "node:test";
import { decodeOneTalkRawContent } from "../src/onetalk/main-page/message-observer/content-decoder.ts";
import { parseOneTalkMessages } from "../src/onetalk/main-page/message-observer/index.ts";
import { observedMessage } from "../src/onetalk/main-page/message-observer/model.ts";
const base64Json = (value) => Buffer.from(JSON.stringify(value), "utf8").toString("base64");
const redirect = (action, id) =>
`https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=onetalk&fileAction=${action}&id=${id}&parentId=parent-1&scene=im&secOperateAliId=operation-1`;
const thumbnail = (id) =>
`https://clouddisk.alibaba.com/file/videoThumb.htm?appkey=onetalk&id=${id}&parentId=parent-1&scene=im&secOperateAliId=operation-1`;
const imagePayload = {
fileId: "image-jpeg-1",
suffix: "JPG",
size: 263_333,
width: 1_280,
height: 720,
isOriginal: 1,
md5: "f28f1f8f4b760d5e2a89c3f0f83f3f68",
url: redirect("imagePreview", "image-jpeg-1"),
};
const filePayload = (extensionType, name, action, id) => ({
cardType: 12,
params: {
extensionType,
id,
parentId: "parent-1",
md5: "a614ee55b22a6545c2bc7342898c6a6f",
name,
size: "8192",
url: redirect(action, id),
thumbnailUrl: thumbnail(id),
downloadUrl: "",
},
});
const rawImage = (payload = imagePayload) => ({
contentType: 101,
custom: { type: 7, data: base64Json(payload) },
});
const rawFile = (payload) => ({
contentType: 101,
custom: { type: 10010, data: base64Json(payload) },
});
test("normalizes real-shape JPEG, ZIP, PDF, and generic cardType=12 files", () => {
assert.deepEqual(decodeOneTalkRawContent(rawImage()), {
status: "decoded",
content: {
version: 1,
kind: "image",
fileId: "image-jpeg-1",
extension: "jpg",
sizeBytes: 263_333,
width: 1_280,
height: 720,
isOriginal: true,
md5: "f28f1f8f4b760d5e2a89c3f0f83f3f68",
previewUrl: redirect("imagePreview", "image-jpeg-1"),
urlScope: "onetalk_session",
},
});
const zip = decodeOneTalkRawContent(
rawFile(filePayload("ZIP", "archive.zip", "download", "zip-1")),
);
assert.equal(zip.status, "decoded");
assert.deepEqual(zip.content, {
version: 1,
kind: "file",
fileId: "zip-1",
parentId: "parent-1",
fileName: "archive.zip",
extension: "zip",
sizeBytes: 8_192,
md5: "a614ee55b22a6545c2bc7342898c6a6f",
previewUrl: null,
thumbnailUrl: thumbnail("zip-1"),
downloadUrl: redirect("download", "zip-1"),
downloadState: "available",
urlScope: "onetalk_session",
});
const pdf = decodeOneTalkRawContent(
rawFile(filePayload("pdf", "quotation.pdf", "officePreview", "pdf-1")),
);
assert.equal(pdf.status, "decoded");
assert.equal(pdf.content.previewUrl, redirect("officePreview", "pdf-1"));
assert.equal(pdf.content.downloadUrl, null);
assert.equal(pdf.content.downloadState, "not_provided");
const generic = decodeOneTalkRawContent(
rawFile(filePayload("docx", "offer.docx", "download", "docx-1")),
);
assert.equal(generic.status, "decoded");
assert.equal(generic.content.extension, "docx");
});
test("skips a legal non-file business card and aggregates every safe media anomaly", () => {
assert.deepEqual(decodeOneTalkRawContent(rawFile({ cardType: 2000, params: {} })), {
status: "unsupported_skipped",
});
const cases = [
[{ contentType: 101, custom: { type: 7, data: "not-base64" } }, "media_invalid_base64"],
[
{
contentType: 101,
custom: { type: 7, data: Buffer.from([0xc3, 0x28]).toString("base64") },
},
"media_invalid_utf8",
],
[
{
contentType: 101,
custom: { type: 7, data: Buffer.from("{", "utf8").toString("base64") },
},
"media_invalid_json",
],
[
{ contentType: 101, custom: { type: 7, data: "AAAA".repeat(131_073) } },
"media_payload_too_large",
],
[rawImage({ ...imagePayload, width: "wide" }), "media_invalid_schema"],
[rawImage({ ...imagePayload, url: "https://evil.example/preview" }), "media_invalid_url"],
];
for (const [raw, code] of cases) {
assert.deepEqual(decodeOneTalkRawContent(raw), {
status: "anomaly",
code,
mediaKind: "image",
});
}
});
test("enforces file suffix and URL action rules while preserving missing URL states", () => {
const noUrls = decodeOneTalkRawContent(
rawFile({
...filePayload("pdf", "quotation.pdf", "officePreview", "pdf-no-url"),
params: {
...filePayload("pdf", "quotation.pdf", "officePreview", "pdf-no-url").params,
url: "",
thumbnailUrl: "",
downloadUrl: "",
},
}),
);
assert.equal(noUrls.status, "decoded");
assert.equal(noUrls.content.previewUrl, null);
assert.equal(noUrls.content.thumbnailUrl, null);
assert.equal(noUrls.content.downloadUrl, null);
assert.equal(noUrls.content.downloadState, "not_provided");
assert.deepEqual(
decodeOneTalkRawContent(
rawFile(filePayload("pdf", "quotation.zip", "download", "mismatched-extension")),
),
{ status: "anomaly", code: "media_invalid_schema", mediaKind: "file" },
);
assert.deepEqual(
decodeOneTalkRawContent(
rawImage({ ...imagePayload, url: redirect("download", "image-wrong-action") }),
),
{ status: "anomaly", code: "media_invalid_url", mediaKind: "image" },
);
});
const pageWindow = {
currentUserAccountId: "seller-account",
__conversationListFullData__: [{ owner: { accountId: "seller-account", aliId: "seller" } }],
};
const rawMessage = (id, content) => ({
messageId: id,
cid: "buyer-seller#tenant@icbu",
createAt: 100,
content,
sender: { uid: "buyer@icbu" },
unreadCount: 0,
});
const liveBatchFrame = (messages) =>
JSON.stringify({
code: 200,
body: messages.map((message) => ({
singleChatUserConversation: {
lastMessage: { message, readStatus: 0, msgStatus: 1 },
singleChatConversation: { pairFirst: "buyer@icbu", pairSecond: "seller@icbu" },
},
})),
});
const liveFrame = (message) => liveBatchFrame([message]);
const historyFrame = (message) =>
JSON.stringify({
code: 200,
body: { userMessageModels: [{ message, readStatus: 0, msgStatus: 1 }] },
});
test("history and ordinary live JSON paths produce identical normalized media", () => {
const content = rawFile(filePayload("zip", "archive.zip", "download", "history-live-zip"));
const live = parseOneTalkMessages(pageWindow, liveFrame(rawMessage("same", content)));
const history = parseOneTalkMessages(pageWindow, historyFrame(rawMessage("same", content)));
assert.deepEqual(live.diagnostics, {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [],
});
assert.deepEqual(history.diagnostics, live.diagnostics);
const { messageType: _liveType, ...liveMessage } = live.messages[0];
const { messageType: _historyType, ...historyMessage } = history.messages[0];
assert.deepEqual(historyMessage, liveMessage);
assert.equal(JSON.stringify(live).includes("custom"), false);
});
test("continues a same-batch valid message after an invalid media payload", () => {
const valid = rawMessage("valid", {
contentType: 1,
text: { content: "kept", extension: { chatToken: "secret" } },
});
const invalid = rawMessage("invalid", {
contentType: 101,
custom: { type: 7, data: "not-base64-secret" },
});
const result = parseOneTalkMessages(pageWindow, liveFrame(valid));
const mixed = parseOneTalkMessages(
pageWindow,
JSON.stringify({
code: 200,
body: [
{
singleChatUserConversation: {
lastMessage: { message: valid, readStatus: 0, msgStatus: 1 },
singleChatConversation: {
pairFirst: "buyer@icbu",
pairSecond: "seller@icbu",
},
},
},
{
singleChatUserConversation: {
lastMessage: { message: invalid, readStatus: 0, msgStatus: 1 },
singleChatConversation: {
pairFirst: "buyer@icbu",
pairSecond: "seller@icbu",
},
},
},
],
}),
);
assert.equal(result.messages.length, 1);
assert.deepEqual(mixed.messages, result.messages);
assert.deepEqual(mixed.diagnostics, {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [{ code: "media_invalid_base64", mediaKind: "image", count: 1 }],
});
assert.equal(JSON.stringify(mixed).includes("not-base64-secret"), false);
});
test("prioritizes an invalid identity over malformed media and keeps a same-batch valid message", () => {
const valid = rawMessage("valid", { contentType: 1, text: { content: "kept" } });
const invalid = {
...rawMessage("invalid-identity-secret", {
contentType: 101,
custom: { type: 7, data: "malformed-base64" },
}),
sender: { uid: "outside@icbu" },
};
const parsed = parseOneTalkMessages(pageWindow, liveBatchFrame([valid, invalid]));
assert.deepEqual(
parsed.messages.map((message) => message.messageId),
["valid"],
);
assert.deepEqual(parsed.diagnostics, {
unsupportedSkippedCount: 0,
invalidObservationCount: 1,
anomalies: [],
});
assert.equal(JSON.stringify(parsed).includes("invalid-identity-secret"), false);
assert.equal(JSON.stringify(parsed).includes("malformed-base64"), false);
});
test("rejects every malformed direct participant set before decoding media", () => {
const message = rawMessage("invalid-direct-identity", {
contentType: 101,
custom: { type: 7, data: "malformed-base64" },
});
for (const participantIds of [
[],
["buyer@icbu"],
["buyer@icbu", "buyer@icbu"],
["seller@icbu", "other@icbu"],
]) {
assert.deepEqual(observedMessage(message, participantIds, 0, 1, "new", "seller@icbu"), {
status: "invalid_observation",
});
}
assert.deepEqual(
observedMessage(message, ["buyer@icbu", "seller@icbu"], 0, 1, "new", "seller@icbu"),
{ status: "anomaly", code: "media_invalid_base64", mediaKind: "image" },
);
});
@@ -24,9 +24,8 @@ const observedMessage = {
messageType: "new",
conversationId: "conversation-1",
messageId: "message-1",
sentAt: 1_700_000_000_000,
contentType: 1,
text: "hello",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "hello" },
senderId: "buyer@icbu",
participantIds: ["buyer@icbu", "seller@icbu"],
direction: "received",
@@ -71,6 +70,9 @@ class FakePageWindow {
origin: pageOrigin,
};
this.currentUserAccountId = "login-account-1";
this.__conversationListFullData__ = [
{ owner: { accountId: "login-account-1", aliId: "seller" } },
];
this.listeners = [];
this.posted = [];
this.throwOnPost = false;
@@ -139,6 +141,15 @@ const lastPostedMessage = (pageWindow) => {
test("decodes one versioned JSON envelope and rejects malformed shapes", () => {
const observed = createOneTalkPageObservedMessage([observedMessage]);
assert.deepEqual(decodeOneTalkPageMessage(observed), observed);
const invalidObservationDiagnostic = createOneTalkPageObservedMessage([], undefined, {
unsupportedSkippedCount: 0,
invalidObservationCount: 1,
anomalies: [],
});
assert.deepEqual(
decodeOneTalkPageMessage(invalidObservationDiagnostic),
invalidObservationDiagnostic,
);
assert.equal(
decodeOneTalkPageMessage({
...observed,
@@ -186,6 +197,63 @@ test("decodes one versioned JSON envelope and rejects malformed shapes", () => {
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...progressMessage,
historyProgress: { ...progress, rawSdk: { chatToken: "secret" } },
}),
null,
);
for (const batch of [
[{ ...observedMessage, text: "legacy text" }],
[{ ...observedMessage, contentType: 1 }],
[
{
...observedMessage,
content: {
...observedMessage.content,
custom: { data: "raw-content", chatToken: "secret" },
},
},
],
[{ ...observedMessage, rawSdk: { chatToken: "secret" } }],
]) {
assert.equal(decodeOneTalkPageMessage({ ...observed, batch }), null);
}
assert.equal(
decodeOneTalkPageMessage({
...observed,
diagnostics: {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [
{ code: "media_invalid_json", mediaKind: "image", count: 1 },
{ code: "media_invalid_json", mediaKind: "image", count: 1 },
],
},
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...observed,
diagnostics: { unsupportedSkippedCount: 0, anomalies: [] },
}),
null,
);
});
test("rejects observed messages with incomplete direct identity", () => {
for (const overrides of [
{ participantIds: [] },
{ participantIds: ["buyer@icbu"] },
{ participantIds: ["buyer@icbu", "buyer@icbu"] },
{ senderId: "outside@icbu" },
]) {
const observed = createOneTalkPageObservedMessage([{ ...observedMessage, ...overrides }]);
assert.equal(decodeOneTalkPageMessage(observed), null);
}
});
test("forwards only valid current-window MAIN messages once to the named Port", () => {
@@ -361,9 +429,13 @@ test("keeps MAIN observer behavior when the injected sink throws", () => {
);
assert.equal(batches.length, 1);
assert.equal(batches[0][0].messageId, "message-1");
assert.equal(pageWindow.logs.length, 2);
assert.equal(pageWindow.logs[1][0], "[Trade Message Center][OneTalk new message]");
assert.equal(batches[0].messages[0].messageId, "message-1");
assert.deepEqual(batches[0].diagnostics, {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [],
});
assert.equal(pageWindow.logs.length, 0);
});
test("uses the fixed bridge source in command results", () => {
@@ -11,8 +11,7 @@ const completeSentMessage = {
senderId: "sender-1",
direction: "sent",
sentAtMs: 1_700_000_000_000,
content: { text: "secret body" },
contentType: 1,
content: { version: 1, kind: "text", text: "secret body" },
participantIds: ["sender-1", "recipient-1"],
readStatus: 0,
messageStatus: 1,
@@ -67,7 +66,6 @@ test("rejects unknown status and reason without forwarding page strings", () =>
"direction",
"sentAtMs",
"content",
"contentType",
"participantIds",
"readStatus",
"messageStatus",
@@ -10,9 +10,7 @@ const completeSent = (overrides = {}) => ({
senderId: "seller@icbu",
direction: "sent",
sentAtMs: Date.now(),
content: { text: { content: "hello" } },
contentType: 1,
text: "hello",
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
@@ -36,7 +34,8 @@ test("confirms only a complete sent message from the parsed observer batch", asy
const observed = completeSent();
correlator.observe([observed]);
assert.deepEqual(await resultPromise, { status: "confirmed_sent", message: observed });
const { messageType: _messageType, ...confirmed } = observed;
assert.deepEqual(await resultPromise, { status: "confirmed_sent", message: confirmed });
assert.deepEqual(calls, [
{
cid: "conversation-1",
@@ -75,12 +74,12 @@ test("uses a returned message ID before applying the timestamp window", async ()
}));
await new Promise((resolve) => setImmediate(resolve));
const observed = completeSent({
text: "different",
content: { text: { content: "different" } },
content: { version: 1, kind: "text", text: "different" },
sentAtMs: Date.now() + 60_000,
});
correlator.observe([observed]);
assert.deepEqual(await resultPromise, { status: "confirmed_sent", message: observed });
const { messageType: _messageType, ...confirmed } = observed;
assert.deepEqual(await resultPromise, { status: "confirmed_sent", message: confirmed });
const mismatch = createSendObservationCorrelator(5);
const mismatchPromise = mismatch.execute("conversation-1", "hello", undefined, () => ({
@@ -94,18 +93,17 @@ test("uses a returned message ID before applying the timestamp window", async ()
});
});
test("normalizes legacy sentAt only after a candidate ID match", async () => {
test("confirms a normalized text message after a candidate ID match", async () => {
const correlator = createSendObservationCorrelator(100);
const resultPromise = correlator.execute("conversation-1", "hello", undefined, () => ({
id: "message-1",
}));
await new Promise((resolve) => setImmediate(resolve));
const observed = completeSent({ sentAtMs: undefined, sentAt: Date.now() - 60_000 });
const observed = completeSent({ sentAtMs: Date.now() - 60_000 });
correlator.observe([observed]);
const result = await resultPromise;
assert.equal(result.status, "confirmed_sent");
assert.equal(result.message.sentAt, undefined);
assert.equal(result.message.sentAtMs, observed.sentAt);
assert.equal(result.message.sentAtMs, observed.sentAtMs);
});
test("uses a five-second window and never confirms a pre-send observation", async () => {
@@ -212,14 +212,14 @@ test("refreshes hello with the selected SPA identity after a click without URL c
[
{
source: "trade-message-center.onetalk.page-bridge",
version: 1,
version: 2,
type: "onetalk.page.hello",
channelAccountId: "login-account-1",
conversationId: "conversation-1",
},
{
source: "trade-message-center.onetalk.page-bridge",
version: 1,
version: 2,
type: "onetalk.page.hello",
channelAccountId: "login-account-1",
conversationId: "conversation-2",
@@ -19,9 +19,8 @@ const observedMessage = {
messageType: "new",
conversationId: "conversation-1",
messageId: "message-1",
sentAt: 1_700_000_000_000,
contentType: 1,
text: "hello",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "hello" },
senderId: "buyer@icbu",
participantIds: ["buyer@icbu", "seller@icbu"],
direction: "received",
@@ -1,118 +1,136 @@
// 验证 OneTalk IndexedDB 消息存储的建库与幂等写入
// 验证 Service Worker 只写入共享 normalized message 合同
import assert from "node:assert/strict";
import test from "node:test";
import {
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
createOneTalkMessageStore,
createOneTalkSyncStore,
} from "../src/onetalk/service-worker/storage.ts";
const observedMessage = {
messageType: "new",
conversationId: "conversation-1",
const normalizedMessage = {
messageId: "message-1",
sentAt: 1_700_000_000_000,
contentType: 1,
text: "hello",
conversationId: "conversation-1",
senderId: "buyer@icbu",
participantIds: ["buyer@icbu", "seller@icbu"],
direction: "received",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
};
class FakeDatabase {
class FakeRequest {
constructor(result) {
this.result = result;
this.error = null;
this.onsuccess = null;
this.onerror = null;
queueMicrotask(() => this.onsuccess?.());
}
}
class FakeStore {
constructor() {
this.records = new Map();
this.storeNames = new Set();
this.objectStoreNames = {
contains: (name) => this.storeNames.has(name),
};
}
put(record) {
this.records.set(record.key, structuredClone(record));
}
get(key) {
return new FakeRequest(this.records.get(key));
}
getAll() {
return new FakeRequest([...this.records.values()].map((record) => structuredClone(record)));
}
clear() {
this.records.clear();
}
}
class FakeTransaction {
constructor(database) {
this.database = database;
this.error = null;
this.oncomplete = null;
this.onerror = null;
this.onabort = null;
setTimeout(() => this.oncomplete?.(), 0);
}
objectStore(name) {
return this.database.stores.get(name);
}
}
class FakeDatabase {
constructor() {
this.version = 0;
this.stores = new Map();
this.objectStoreNames = { contains: (name) => this.stores.has(name) };
}
createObjectStore(name) {
assert.equal(
[
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
].includes(name),
true,
);
this.storeNames.add(name);
return {};
const store = new FakeStore();
this.stores.set(name, store);
return store;
}
transaction() {
const transaction = {
error: null,
oncomplete: null,
onerror: null,
onabort: null,
objectStore: () => ({
put: (record) => this.records.set(record.key, record),
}),
};
queueMicrotask(() => transaction.oncomplete?.());
return transaction;
return new FakeTransaction(this);
}
}
class FakeRequest {
result = null;
error = null;
onupgradeneeded = null;
onsuccess = null;
onerror = null;
onblocked = null;
}
class FakeFactory {
constructor() {
this.database = new FakeDatabase();
}
class FakeIndexedDbFactory {
database = new FakeDatabase();
open() {
const request = new FakeRequest();
queueMicrotask(() => {
request.result = this.database;
request.onupgradeneeded?.();
queueMicrotask(() => request.onsuccess?.());
});
open(_name, version) {
const request = new FakeRequest(this.database);
request.onsuccess = null;
if (this.database.version < version) {
request.transaction = new FakeTransaction(this.database);
request.transaction.oncomplete = () => request.onsuccess?.();
queueMicrotask(() => {
const oldVersion = this.database.version;
this.database.version = version;
request.onupgradeneeded?.({ oldVersion, newVersion: version });
});
}
return request;
}
}
test("creates the message store and overwrites duplicate business keys", async () => {
const factory = new FakeIndexedDbFactory();
const store = createOneTalkMessageStore(factory);
test("persists only normalized content and refuses a raw observation without a candidate", async () => {
const factory = new FakeFactory();
const store = createOneTalkSyncStore(factory, () => 500);
await store.putBatch("account-1", [observedMessage, observedMessage]);
assert.deepEqual(
[...factory.database.storeNames].sort(),
[
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
].sort(),
);
assert.equal(factory.database.records.size, 1);
const result = await store.persistObservedBatch({
channelAccountId: "account-1",
conversationId: "conversation-1",
messages: [
normalizedMessage,
{ ...normalizedMessage, messageId: "raw-1", content: { custom: { data: "secret" } } },
],
observationSource: "history",
mode: "full",
receivedAt: 500,
});
await store.putBatch("account-1", [
{
...observedMessage,
text: "updated",
},
]);
assert.equal(factory.database.records.size, 1);
assert.equal([...factory.database.records.values()][0].text, "updated");
assert.equal(result.candidates.length, 1);
assert.equal(result.anomalies.length, 1);
assert.equal(result.anomalies[0].code, "invalid_message");
const stored = factory.database.stores.get(ONE_TALK_MESSAGE_STORE_NAME).records;
const candidates = factory.database.stores.get(ONE_TALK_CANDIDATE_STORE_NAME).records;
assert.equal(stored.size, 1);
assert.equal(candidates.size, 1);
assert.equal(JSON.stringify([...stored.values()]).includes("custom"), false);
assert.equal(JSON.stringify([...candidates.values()]).includes("secret"), false);
});
@@ -16,8 +16,7 @@ const rawMessage = (messageId, sentAtMs) => ({
senderId: "sender-1",
direction: "received",
sentAtMs,
content: { text: messageId },
contentType: 1,
content: { version: 1, kind: "text", text: messageId },
participantIds: ["sender-1", "login-1"],
readStatus: 0,
messageStatus: 1,
@@ -432,7 +431,7 @@ test("records a page observation without conversation identity as an anomaly", a
const result = await engine.handlePageObservation(
{
source: "trade-message-center.onetalk.page-bridge",
version: 1,
version: 2,
type: "onetalk.page.observed",
batch: [{ messageType: "new", senderId: "sender-1" }],
},
@@ -444,6 +443,86 @@ test("records a page observation without conversation identity as an anomaly", a
assert.equal((await store.listCheckpoints(scope.channelAccountId)).length, 0);
});
test("records aggregated media diagnostics without creating a candidate or anchor", async () => {
const store = new MemoryStore();
const engine = createOneTalkSyncEngine({
scope,
bright: new FakeBright(),
store,
pageRuntime: {
routePageCommand: async () => ({ status: "completed", historyComplete: true }),
},
now: () => 500,
});
const result = await engine.handlePageObservation(
{
source: "trade-message-center.onetalk.page-bridge",
version: 2,
type: "onetalk.page.observed",
batch: [],
diagnostics: {
unsupportedSkippedCount: 1,
invalidObservationCount: 3,
anomalies: [{ code: "media_invalid_json", mediaKind: "file", count: 2 }],
},
},
scope.channelAccountId,
);
assert.deepEqual(result.candidates, []);
assert.deepEqual(
result.anomalies.map((anomaly) => [anomaly.code, anomaly.occurrenceCount, anomaly.fields]),
[
["unsupported_skipped", 1, ["card"]],
["invalid_observation", 3, ["observation"]],
["media_invalid_json", 2, ["file"]],
],
);
assert.equal(store.candidates.size, 0);
assert.equal(JSON.stringify(result).includes("custom"), false);
});
test("marks a valid batch with media diagnostics as succeeded_with_anomalies", async () => {
const store = new MemoryStore();
const engine = createOneTalkSyncEngine({
scope,
bright: new FakeBright(),
store,
pageRuntime: { routePageCommand: async () => ({ status: "completed" }) },
now: () => 505,
});
await engine.handlePageObservation(
{
source: "trade-message-center.onetalk.page-bridge",
version: 2,
type: "onetalk.page.observed",
batch: [
{ messageType: "new", ...rawMessage("valid-with-anomaly", 505) },
{
messageType: "new",
...rawMessage("valid-with-anomaly-2", 506),
conversationId: "conversation-2",
},
],
diagnostics: {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [{ code: "media_invalid_json", mediaKind: "file", count: 1 }],
},
},
scope.channelAccountId,
);
for (const conversationId of ["conversation-1", "conversation-2"]) {
assert.equal(
(await store.getCheckpoint(scope.channelAccountId, conversationId)).syncResult,
"succeeded_with_anomalies",
);
}
});
test("treats passive history observations as full facts without an active incremental run", async () => {
const store = new MemoryStore();
const bright = new FakeBright();
@@ -905,7 +984,7 @@ test("persists a page checkpoint without uploading before the history boundary",
await engine.handlePageObservation(
{
source: "trade-message-center.onetalk.page-bridge",
version: 1,
version: 2,
type: "onetalk.page.observed",
batch: [],
historyProgress: {
@@ -969,7 +1048,7 @@ test("persists page progress while the page command is still scanning", async ()
await engine.handlePageObservation(
{
source: "trade-message-center.onetalk.page-bridge",
version: 1,
version: 2,
type: "onetalk.page.observed",
batch: [],
historyProgress: {
@@ -1027,7 +1106,7 @@ test("persists terminal page progress and resumes upload without rescanning", as
await engine.handlePageObservation(
{
source: "trade-message-center.onetalk.page-bridge",
version: 1,
version: 2,
type: "onetalk.page.observed",
batch: [],
historyProgress: {
@@ -18,8 +18,7 @@ const validMessage = {
senderId: "sender-1",
direction: "received",
sentAtMs: 100,
content: { text: "hello" },
contentType: 1,
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "login-1"],
readStatus: 0,
messageStatus: 1,
@@ -53,6 +52,10 @@ class FakeStore {
return new FakeRequest([...this.records.values()].map((record) => structuredClone(record)));
}
clear() {
this.records.clear();
}
openCursor() {
const entries = [...this.records.entries()];
const request = new FakeRequest(null, false);
@@ -227,89 +230,40 @@ test("creates durable stores, keeps confirmed candidates, and merges anomalies",
);
});
test("migrates v2 records by removing only the legacy message field", async () => {
const factory = createVersionTwoFactory();
const messageKey = JSON.stringify(["account-1", "conversation-1", "legacy-message"]);
const candidateKey = JSON.stringify(["account-1", "conversation-1", "legacy-message"]);
const oldMessage = {
key: messageKey,
channelAccountId: "account-1",
conversationId: "conversation-1",
messageId: "legacy-message",
senderId: "sender-1",
loginUserId: "legacy-login-user",
direction: "received",
sentAtMs: 100,
content: { text: "legacy" },
contentType: 1,
participantIds: ["sender-1", "seller-1"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
receivedAt: 101,
};
const oldCandidate = {
key: candidateKey,
channelAccountId: "account-1",
conversationId: "conversation-1",
messageId: "legacy-message",
message: { ...oldMessage },
observationSource: "incremental",
status: "pending_ack",
requestId: "request-1",
firstObservedAt: 102,
updatedAt: 103,
};
const oldAnomaly = {
key: "anomaly-1",
channelAccountId: "account-1",
conversationId: "conversation-1",
code: "missing_sender_id",
observationSource: "history",
fields: ["senderId"],
occurrenceCount: 2,
firstObservedAt: 104,
lastObservedAt: 105,
};
factory.database.stores.get(ONE_TALK_MESSAGE_STORE_NAME).put(oldMessage);
factory.database.stores.get(ONE_TALK_CANDIDATE_STORE_NAME).put(oldCandidate);
factory.database.stores.get(ONE_TALK_CHECKPOINT_STORE_NAME).put(checkpoint);
factory.database.stores.get(ONE_TALK_ANOMALY_STORE_NAME).put(oldAnomaly);
test("clears all five OneTalk stores from v5 before v6 resumes sync", async () => {
for (const oldVersion of [0, 4, 5]) {
const factory = new FakeFactory(oldVersion);
const extensionStorage = new Map([
["onetalk.config", { channelAccountId: "account-1" }],
["onetalk.deviceId", "device-1"],
]);
for (const storeName of [
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
]) {
factory.database.createObjectStore(storeName).put({ key: storeName, legacy: true });
}
const store = createOneTalkSyncStore(factory, () => 500);
const migratedCandidate = await store.getCandidate(
"account-1",
"conversation-1",
"legacy-message",
);
const migratedMessage = factory.database.stores
.get(ONE_TALK_MESSAGE_STORE_NAME)
.records.get(messageKey);
const migratedCheckpoint = await store.getCheckpoint("account-1", "conversation-1");
const migratedAnomalies = await store.listAnomalies("account-1", "conversation-1");
assert.equal("loginUserId" in migratedMessage, false);
assert.equal("loginUserId" in migratedCandidate.message, false);
assert.equal(migratedCandidate.key, candidateKey);
assert.equal(migratedCandidate.status, "pending_ack");
assert.equal(migratedCandidate.requestId, "request-1");
assert.equal(migratedMessage.senderId, oldMessage.senderId);
assert.equal(migratedMessage.receivedAt, oldMessage.receivedAt);
assert.deepEqual(migratedCheckpoint, checkpoint);
assert.deepEqual(migratedAnomalies, [oldAnomaly]);
await store.persistObservedBatch({
channelAccountId: "account-1",
conversationId: "conversation-1",
messages: [{ ...validMessage, loginUserId: "must-not-persist" }],
observationSource: "history",
mode: "full",
receivedAt: 506,
});
const newCandidate = await store.getCandidate("account-1", "conversation-1", "message-1");
const newMessage = factory.database.stores
.get(ONE_TALK_MESSAGE_STORE_NAME)
.records.get(JSON.stringify(["account-1", "conversation-1", "message-1"]));
assert.equal("loginUserId" in newMessage, false);
assert.equal("loginUserId" in newCandidate.message, false);
const store = createOneTalkSyncStore(factory, () => 500);
assert.deepEqual(await store.listCheckpoints("account-1"), []);
for (const storeName of [
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
]) {
assert.equal(factory.database.stores.get(storeName).records.size, 0);
}
assert.deepEqual(
[...extensionStorage.entries()],
[
["onetalk.config", { channelAccountId: "account-1" }],
["onetalk.deviceId", "device-1"],
],
);
}
});
@@ -1,26 +1,20 @@
// 验证 OneTalk WebSocket 消息观察行为
// 验证 OneTalk WebSocket observer 只输出 normalized 消息与安全诊断
import assert from "node:assert/strict";
import test from "node:test";
import { installOneTalkMessageObserver } from "../src/onetalk/main-page/message-observer/entry.ts";
// Account, ali, and participant UID are distinct OneTalk identity representations.
const SELF_ACCOUNT_ID = "286995452";
const SELF_PARTICIPANT = "2500002169502@icbu";
const CONTACT_PARTICIPANT = "2208314000798@icbu";
const SELF_ACCOUNT_ID = "286995452";
const SELF_ALI_ID = "2500002169502";
class FakeWebSocket extends EventTarget {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
constructor(url, protocols) {
constructor(url) {
super();
this.url = String(url);
this.protocols = protocols;
}
receive(data) {
@@ -28,330 +22,189 @@ class FakeWebSocket extends EventTarget {
}
}
function testPageWindow() {
const testPageWindow = () => {
const logs = [];
return {
logs,
pageWindow: {
WebSocket: FakeWebSocket,
// Self identity comes from the page login account, never the URL contact.
currentUserAccountId: SELF_ACCOUNT_ID,
__conversationListFullData__: [
{ owner: { accountId: SELF_ACCOUNT_ID, aliId: SELF_ALI_ID } },
{ owner: { accountId: SELF_ACCOUNT_ID, aliId: "2500002169502" } },
],
console: {
log(...args) {
logs.push(args);
},
},
console: { log: (...args) => logs.push(args) },
},
};
}
const newMessageFrame = ({ senderId, pair, participantIds } = {}) => {
const singleChatConversation =
participantIds === undefined
? { pairFirst: pair?.[0], pairSecond: pair?.[1] }
: { participantIds };
const message = {
messageId: "realtime-probe",
cid: "2208314000798-2500002169502#11011@icbu",
createAt: 1_787_649_815_828,
content: { text: { content: "probe" }, contentType: 1 },
...(senderId === undefined ? {} : { sender: { uid: senderId } }),
};
return JSON.stringify({
code: 200,
body: [
{
singleChatUserConversation: {
lastMessage: { message, readStatus: 2, msgStatus: 1 },
singleChatConversation,
},
},
],
});
};
test("prints raw messages from the OneTalk WebSocket", () => {
const { logs, pageWindow } = testPageWindow();
installOneTalkMessageObserver(pageWindow);
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
const payload = { raw: "unchanged" };
socket.receive(payload);
assert.ok(socket instanceof FakeWebSocket);
assert.equal(pageWindow.WebSocket.OPEN, FakeWebSocket.OPEN);
assert.equal(logs.length, 1);
assert.equal(logs[0][1], payload);
const rawTextMessage = (messageId = "message-1", text = "hello") => ({
messageId,
cid: "2208314000798-2500002169502#11011@icbu",
createAt: 1_787_649_815_828,
content: { contentType: 1, text: { content: text, extension: { ignored: true } } },
sender: { uid: CONTACT_PARTICIPANT },
unreadCount: 0,
});
test("does not print OneTalk heartbeat responses", () => {
const { logs, pageWindow } = testPageWindow();
installOneTalkMessageObserver(pageWindow);
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
socket.receive(
JSON.stringify({
headers: { mid: "8451787883957878 0", "server-timestamp": "1787883958116" },
code: 200,
}),
);
assert.deepEqual(logs, []);
});
test("prints plaintext messages from conversation response frames", () => {
const { logs, pageWindow } = testPageWindow();
installOneTalkMessageObserver(pageWindow);
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
const message = {
messageId: "4264696193791.PNM",
cid: "2208314000798-2500002169502#11011@icbu",
createAt: 1787649815828,
content: { text: { content: "找你真难" }, contentType: 1 },
sender: { uid: "2208314000798@icbu" },
unreadCount: 0,
};
const frame = JSON.stringify({
const liveFrame = (message, pair = [CONTACT_PARTICIPANT, SELF_PARTICIPANT]) =>
JSON.stringify({
code: 200,
body: [
{
singleChatUserConversation: {
lastMessage: { message, readStatus: 2, msgStatus: 1 },
singleChatConversation: {
pairFirst: "2208314000798@icbu",
pairSecond: "2500002169502@icbu",
pairFirst: pair[0],
pairSecond: pair[1],
},
},
},
],
});
socket.receive(frame);
assert.equal(logs.length, 2);
assert.equal(logs[1][0], "[Trade Message Center][OneTalk new message]");
assert.equal(logs[0][1], frame);
assert.deepEqual(logs[1][1], {
messageType: "new",
conversationId: "2208314000798-2500002169502#11011@icbu",
messageId: "4264696193791.PNM",
sentAt: 1787649815828,
content: { text: { content: "找你真难" }, contentType: 1 },
contentType: 1,
text: "找你真难",
senderId: "2208314000798@icbu",
participantIds: ["2208314000798@icbu", "2500002169502@icbu"],
direction: "received",
readStatus: 2,
messageStatus: 1,
unreadCount: 0,
});
});
test("resolves realtime direction independently of participant order", () => {
const { pageWindow } = testPageWindow();
const batches = [];
installOneTalkMessageObserver(pageWindow, (batch) => batches.push(...batch));
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
const orders = [
[SELF_PARTICIPANT, CONTACT_PARTICIPANT],
[CONTACT_PARTICIPANT, SELF_PARTICIPANT],
];
for (const pair of orders) {
socket.receive(newMessageFrame({ senderId: SELF_PARTICIPANT, pair }));
socket.receive(newMessageFrame({ senderId: CONTACT_PARTICIPANT, pair }));
}
assert.equal(batches.length, 4);
const sent = [batches[0], batches[2]];
const received = [batches[1], batches[3]];
assert.equal(sent[0].senderId, SELF_PARTICIPANT);
assert.equal(sent[0].senderId, sent[1].senderId);
assert.deepEqual(sent[0].participantIds, orders[0]);
assert.deepEqual(sent[1].participantIds, orders[1]);
assert.equal(sent[0].direction, "sent");
assert.equal(sent[1].direction, "sent");
assert.equal(received[0].senderId, CONTACT_PARTICIPANT);
assert.equal(received[0].senderId, received[1].senderId);
assert.deepEqual(received[0].participantIds, orders[0]);
assert.deepEqual(received[1].participantIds, orders[1]);
assert.equal(received[0].direction, "received");
assert.equal(received[1].direction, "received");
});
test("omits direction when page identity or message participants are invalid", () => {
const scenarios = [
{
name: "missing page self",
currentUserAccountId: undefined,
senderId: SELF_PARTICIPANT,
pair: [SELF_PARTICIPANT, CONTACT_PARTICIPANT],
},
{
name: "sender outside pair",
senderId: "777777@icbu",
pair: [SELF_PARTICIPANT, CONTACT_PARTICIPANT],
},
{
name: "missing sender",
pair: [SELF_PARTICIPANT, CONTACT_PARTICIPANT],
},
{
name: "duplicate participants",
senderId: SELF_PARTICIPANT,
pair: [SELF_PARTICIPANT, SELF_PARTICIPANT],
},
{
name: "malformed participant",
senderId: SELF_PARTICIPANT,
pair: [SELF_PARTICIPANT, "2208314000798"],
},
{
name: "single participant",
senderId: SELF_PARTICIPANT,
participantIds: [SELF_PARTICIPANT],
},
{
name: "three participants",
senderId: SELF_PARTICIPANT,
participantIds: [SELF_PARTICIPANT, CONTACT_PARTICIPANT, "777777@icbu"],
},
];
for (const scenario of scenarios) {
const { pageWindow } = testPageWindow();
pageWindow.currentUserAccountId = Object.hasOwn(scenario, "currentUserAccountId")
? scenario.currentUserAccountId
: SELF_ACCOUNT_ID;
const batches = [];
installOneTalkMessageObserver(pageWindow, (batch) => batches.push(...batch));
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
socket.receive(newMessageFrame(scenario));
assert.equal(batches.length, 1, scenario.name);
assert.equal(Object.hasOwn(batches[0], "direction"), false, scenario.name);
}
});
test("prints messages from history response frames", () => {
const { logs, pageWindow } = testPageWindow();
installOneTalkMessageObserver(pageWindow);
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
const frame = JSON.stringify({
const historyFrame = (message) =>
JSON.stringify({
code: 200,
body: {
nextCursor: 1787157210197,
hasMore: 1,
userMessageModels: [
body: { userMessageModels: [{ message, readStatus: 2, msgStatus: 1 }] },
});
const observe = (frame) => {
const { logs, pageWindow } = testPageWindow();
const batches = [];
installOneTalkMessageObserver(pageWindow, (batch) => batches.push(batch));
new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/").receive(frame);
return { batches, logs };
};
test("normalizes text before the sink and does not log raw frames or bodies", () => {
const { batches, logs } = observe(liveFrame(rawTextMessage("message-1", "sensitive body")));
assert.deepEqual(batches, [
{
messages: [
{
messageType: "new",
messageId: "message-1",
conversationId: "2208314000798-2500002169502#11011@icbu",
senderId: CONTACT_PARTICIPANT,
direction: "received",
sentAtMs: 1_787_649_815_828,
content: { version: 1, kind: "text", text: "sensitive body" },
participantIds: [CONTACT_PARTICIPANT, SELF_PARTICIPANT],
readStatus: 2,
msgStatus: 1,
message: {
messageId: "4268023992386.PNM",
cid: "2208314000798-2500002169502#11011@icbu",
createAt: 1787212003008,
content: { text: { content: "我需要深色的" }, contentType: 1 },
sender: { uid: "2500002169502@icbu" },
unreadCount: 0,
},
},
{
readStatus: 2,
msgStatus: 1,
message: {
messageId: "4260308763261.PNM",
cid: "2208314000798-2500002169502#11011@icbu",
createAt: 1787210475840,
content: { custom: { type: 10010 }, contentType: 101 },
sender: { uid: "2500002169502@icbu" },
unreadCount: 0,
},
},
{
readStatus: 2,
msgStatus: 1,
message: {
messageId: "4268023992387.PNM",
cid: "2500002169502-2208314000798#11011@icbu",
createAt: 1787212003009,
content: { text: { content: "收到" }, contentType: 1 },
sender: { uid: "2208314000798@icbu" },
unreadCount: 0,
},
messageStatus: 1,
unreadCount: 0,
},
],
diagnostics: { unsupportedSkippedCount: 0, invalidObservationCount: 0, anomalies: [] },
},
});
socket.receive(frame);
assert.equal(logs.length, 4);
assert.equal(logs[1][0], "[Trade Message Center][OneTalk history message]");
assert.deepEqual(logs[1][1], {
messageType: "history",
conversationId: "2208314000798-2500002169502#11011@icbu",
messageId: "4268023992386.PNM",
sentAt: 1787212003008,
content: { text: { content: "我需要深色的" }, contentType: 1 },
contentType: 1,
text: "我需要深色的",
senderId: "2500002169502@icbu",
participantIds: ["2208314000798@icbu", "2500002169502@icbu"],
direction: "sent",
readStatus: 2,
messageStatus: 1,
unreadCount: 0,
});
assert.equal(logs[2][0], "[Trade Message Center][OneTalk history message]");
assert.equal(logs[2][1].contentType, 101);
assert.deepEqual(logs[2][1].content, { custom: { type: 10010 }, contentType: 101 });
assert.equal(logs[2][1].text, null);
assert.deepEqual(
{
senderId: logs[3][1].senderId,
participantIds: logs[3][1].participantIds,
direction: logs[3][1].direction,
},
{
senderId: "2208314000798@icbu",
participantIds: ["2500002169502@icbu", "2208314000798@icbu"],
direction: "received",
},
);
]);
assert.deepEqual(logs, []);
assert.equal(JSON.stringify(batches).includes("extension"), false);
});
test("does not treat sync push packages as plaintext messages", () => {
const { logs, pageWindow } = testPageWindow();
test("does not log OneTalk heartbeat responses", () => {
const { batches, logs } = observe(
JSON.stringify({
headers: { mid: "heartbeat-1", "server-timestamp": "1700000000000" },
code: 200,
}),
);
installOneTalkMessageObserver(pageWindow);
const socket = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com/");
socket.receive(
assert.deepEqual(batches, []);
assert.deepEqual(logs, []);
});
test("history and live use the same normalized decoder output", () => {
const live = observe(liveFrame(rawTextMessage("same-message"))).batches[0].messages[0];
const history = observe(historyFrame(rawTextMessage("same-message"))).batches[0].messages[0];
const { messageType: _liveType, ...liveMessage } = live;
const { messageType: _historyType, ...historyMessage } = history;
assert.deepEqual(historyMessage, liveMessage);
});
test("preserves participant order while resolving sent and received direction", () => {
for (const pair of [
[SELF_PARTICIPANT, CONTACT_PARTICIPANT],
[CONTACT_PARTICIPANT, SELF_PARTICIPANT],
]) {
const sent = observe(
liveFrame({ ...rawTextMessage("sent"), sender: { uid: SELF_PARTICIPANT } }, pair),
).batches[0].messages[0];
const received = observe(
liveFrame(
{ ...rawTextMessage("received"), sender: { uid: CONTACT_PARTICIPANT } },
pair,
),
).batches[0].messages[0];
assert.deepEqual(sent.participantIds, pair);
assert.equal(sent.direction, "sent");
assert.deepEqual(received.participantIds, pair);
assert.equal(received.direction, "received");
}
});
test("reports media failures through a deduplicated safe diagnostic without raw leakage", () => {
const raw = rawTextMessage("bad-media", "unused");
raw.content = { contentType: 101, custom: { type: 7, data: "not-base64-secret" } };
const { batches, logs } = observe(liveFrame(raw));
assert.deepEqual(batches[0], {
messages: [],
diagnostics: {
unsupportedSkippedCount: 0,
invalidObservationCount: 0,
anomalies: [{ code: "media_invalid_base64", mediaKind: "image", count: 1 }],
},
});
assert.equal(JSON.stringify(batches).includes("not-base64-secret"), false);
assert.equal(JSON.stringify(logs).includes("not-base64-secret"), false);
});
test("emits an invalid_observation diagnostic without forwarding the invalid message", () => {
const invalid = {
...rawTextMessage("invalid-message-id", "discarded body"),
sender: { uid: "outside@icbu" },
};
const { batches, logs } = observe(liveFrame(invalid));
assert.deepEqual(batches, [
{
messages: [],
diagnostics: {
unsupportedSkippedCount: 0,
invalidObservationCount: 1,
anomalies: [],
},
},
]);
assert.deepEqual(logs, [
["[Trade Message Center][OneTalk WebSocket]", { event: "invalid_observation" }],
]);
assert.equal(JSON.stringify(batches).includes("invalid-message-id"), false);
assert.equal(JSON.stringify(logs).includes("discarded body"), false);
});
test("does not treat MessagePack sync push as ordinary live media support", () => {
const { batches, logs } = observe(
JSON.stringify({
lwp: "/s/sync",
body: { syncPushPackage: { data: [{ data: "encoded" }] } },
}),
);
assert.equal(logs.length, 1);
assert.deepEqual(batches, []);
assert.deepEqual(logs, []);
});
test("ignores other hosts and installs only once", () => {
test("ignores other WebSocket hosts and installs the tap only once", () => {
const { logs, pageWindow } = testPageWindow();
installOneTalkMessageObserver(pageWindow);
const installedConstructor = pageWindow.WebSocket;
const installed = pageWindow.WebSocket;
installOneTalkMessageObserver(pageWindow);
assert.equal(pageWindow.WebSocket, installedConstructor);
const unrelated = new pageWindow.WebSocket("wss://example.com/");
const lookalike = new pageWindow.WebSocket("wss://wss-icbu.dingtalk.com.example.com/");
unrelated.receive("unrelated");
lookalike.receive("lookalike");
new pageWindow.WebSocket("wss://example.com/").receive(liveFrame(rawTextMessage()));
assert.equal(pageWindow.WebSocket, installed);
assert.deepEqual(logs, []);
});
+343
View File
@@ -0,0 +1,343 @@
// 定义 OneTalk 归一化消息内容和 Mind 读取模型
import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./model.ts";
export const ONETALK_CONTENT_VERSION = 1 as const;
export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3;
export const ONETALK_MAX_IMAGE_DIMENSION_PX = 65_535;
export const ONETALK_CONTENT_KINDS = ["text", "image", "file"] as const;
export type OneTalkMessageContentKind = (typeof ONETALK_CONTENT_KINDS)[number];
export const ONETALK_CENTER_READ_STATUSES = ["read", "unread"] as const;
export type OneTalkCenterReadStatus = (typeof ONETALK_CENTER_READ_STATUSES)[number];
export type OneTalkTextContent = {
version: typeof ONETALK_CONTENT_VERSION;
kind: "text";
text: string;
};
export type OneTalkImageContent = {
version: typeof ONETALK_CONTENT_VERSION;
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: typeof ONETALK_CONTENT_VERSION;
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;
/** Mind HTTP history 和 message.created 共用的公开消息读取模型。 */
export type OneTalkCenterMessage = {
messageId: string;
conversationId: string;
senderId: string;
participantIds: string[];
direction: OneTalkDirection;
sentAtMs: number;
readStatus: OneTalkCenterReadStatus;
content: OneTalkMessageContent;
};
export type OneTalkMessageContentDecodeResult =
| { ok: true; content: OneTalkMessageContent }
| { ok: false };
const MAX_IDENTIFIER_LENGTH = 512;
const MAX_TEXT_LENGTH = 64 * 1024;
const MAX_FILE_NAME_LENGTH = 255;
const MAX_EXTENSION_LENGTH = 64;
const MAX_MD5_LENGTH = 128;
const MAX_MEDIA_URL_LENGTH = 8 * 1024;
const CONTENT_KEYS = {
text: ["version", "kind", "text"],
image: [
"version",
"kind",
"fileId",
"extension",
"sizeBytes",
"width",
"height",
"isOriginal",
"md5",
"previewUrl",
"urlScope",
],
file: [
"version",
"kind",
"fileId",
"parentId",
"fileName",
"extension",
"sizeBytes",
"md5",
"previewUrl",
"thumbnailUrl",
"downloadUrl",
"downloadState",
"urlScope",
],
} as const;
const CENTER_MESSAGE_KEYS = [
"messageId",
"conversationId",
"senderId",
"participantIds",
"direction",
"sentAtMs",
"readStatus",
"content",
] as const;
const REDIRECT_URL_PATH = "/file/redirectFileUrl.htm";
const THUMBNAIL_URL_PATH = "/file/videoThumb.htm";
const ALLOWED_MEDIA_QUERY_KEYS = [
"appkey",
"fileAction",
"id",
"parentId",
"scene",
"secOperateAliId",
] as const;
const isRecord = (value: unknown): value is Record<string, unknown> => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
const actualKeys = Object.keys(value).sort();
const expectedKeys = [...keys].sort();
return (
actualKeys.length === expectedKeys.length &&
actualKeys.every((key, index) => key === expectedKeys[index])
);
};
const isNonBlankString = (value: unknown, maximumLength: number): value is string => {
return (
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= maximumLength &&
!/[\u0000-\u001f\u007f]/u.test(value)
);
};
/** 判断直接会话的两个参与者是否唯一,且包含消息发送者。 */
export const isOneTalkDirectParticipantSet = (
participantIds: unknown,
senderId: unknown,
): participantIds is [string, string] => {
if (
!Array.isArray(participantIds) ||
participantIds.length !== 2 ||
!isNonBlankString(senderId, MAX_IDENTIFIER_LENGTH) ||
!participantIds.every((participantId) =>
isNonBlankString(participantId, MAX_IDENTIFIER_LENGTH),
)
) {
return false;
}
return new Set(participantIds).size === 2 && participantIds.includes(senderId);
};
const isNonNegativeSafeInteger = (value: unknown): value is number => {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
};
const isBoundedNonNegativeSafeInteger = (value: unknown, maximum: number): value is number => {
return isNonNegativeSafeInteger(value) && value <= maximum;
};
const isExtension = (value: unknown): value is string => {
return (
isNonBlankString(value, MAX_EXTENSION_LENGTH) &&
value === value.toLowerCase() &&
/^[a-z0-9][a-z0-9._-]*$/u.test(value)
);
};
const isMd5 = (value: unknown): value is string | null => {
return value === null || isNonBlankString(value, MAX_MD5_LENGTH);
};
const hasAllowedMediaQuery = (url: URL): boolean => {
const seenKeys = new Set<string>();
for (const [key, value] of url.searchParams) {
if (
!ALLOWED_MEDIA_QUERY_KEYS.includes(key as (typeof ALLOWED_MEDIA_QUERY_KEYS)[number]) ||
seenKeys.has(key) ||
value.length === 0
) {
return false;
}
seenKeys.add(key);
}
return true;
};
const isMediaUrl = (
value: unknown,
expectedPath: string,
allowedFileActions: readonly string[] | null,
): value is string => {
if (
typeof value !== "string" ||
value.length === 0 ||
value.length > MAX_MEDIA_URL_LENGTH ||
value.trim() !== value ||
/\s/u.test(value)
) {
return false;
}
try {
const url = new URL(value);
if (
url.protocol !== "https:" ||
url.hostname !== "clouddisk.alibaba.com" ||
url.port.length > 0 ||
url.username.length > 0 ||
url.password.length > 0 ||
url.hash.length > 0 ||
url.pathname !== expectedPath ||
!hasAllowedMediaQuery(url)
) {
return false;
}
const fileAction = url.searchParams.get("fileAction");
if (allowedFileActions === null) {
return (
fileAction === null ||
["imagePreview", "download", "officePreview"].includes(fileAction)
);
}
return fileAction !== null && allowedFileActions.includes(fileAction);
} catch {
return false;
}
};
const isOptionalMediaUrl = (
value: unknown,
expectedPath: string,
allowedFileActions: readonly string[] | null,
): value is string | null => {
return value === null || isMediaUrl(value, expectedPath, allowedFileActions);
};
const isOneTalkTextContent = (value: Record<string, unknown>): value is OneTalkTextContent => {
return (
hasExactKeys(value, CONTENT_KEYS.text) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "text" &&
isNonBlankString(value.text, MAX_TEXT_LENGTH)
);
};
const isOneTalkImageContent = (value: Record<string, unknown>): value is OneTalkImageContent => {
return (
hasExactKeys(value, CONTENT_KEYS.image) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "image" &&
isNonBlankString(value.fileId, MAX_IDENTIFIER_LENGTH) &&
isExtension(value.extension) &&
isBoundedNonNegativeSafeInteger(value.sizeBytes, ONETALK_MAX_MEDIA_SIZE_BYTES) &&
isBoundedNonNegativeSafeInteger(value.width, ONETALK_MAX_IMAGE_DIMENSION_PX) &&
isBoundedNonNegativeSafeInteger(value.height, ONETALK_MAX_IMAGE_DIMENSION_PX) &&
typeof value.isOriginal === "boolean" &&
isMd5(value.md5) &&
isOptionalMediaUrl(value.previewUrl, REDIRECT_URL_PATH, ["imagePreview"]) &&
value.urlScope === "onetalk_session"
);
};
const isOneTalkFileContent = (value: Record<string, unknown>): value is OneTalkFileContent => {
return (
hasExactKeys(value, CONTENT_KEYS.file) &&
value.version === ONETALK_CONTENT_VERSION &&
value.kind === "file" &&
isNonBlankString(value.fileId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.parentId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.fileName, MAX_FILE_NAME_LENGTH) &&
isExtension(value.extension) &&
value.fileName.toLowerCase().endsWith(`.${value.extension}`) &&
value.fileName.length > value.extension.length + 1 &&
isBoundedNonNegativeSafeInteger(value.sizeBytes, ONETALK_MAX_MEDIA_SIZE_BYTES) &&
isMd5(value.md5) &&
isOptionalMediaUrl(value.previewUrl, REDIRECT_URL_PATH, ["officePreview"]) &&
isOptionalMediaUrl(value.thumbnailUrl, THUMBNAIL_URL_PATH, null) &&
isOptionalMediaUrl(value.downloadUrl, REDIRECT_URL_PATH, ["download"]) &&
((value.downloadState === "available" && value.downloadUrl !== null) ||
(value.downloadState === "not_provided" && value.downloadUrl === null)) &&
value.urlScope === "onetalk_session"
);
};
/** 解码 exact-shape 的已归一化 OneTalk 消息内容。 */
export const decodeOneTalkMessageContent = (value: unknown): OneTalkMessageContentDecodeResult => {
if (!isRecord(value) || value.version !== ONETALK_CONTENT_VERSION) return { ok: false };
if (value.kind === "text" && isOneTalkTextContent(value)) {
return { ok: true, content: value };
}
if (value.kind === "image" && isOneTalkImageContent(value)) {
return { ok: true, content: value };
}
if (value.kind === "file" && isOneTalkFileContent(value)) {
return { ok: true, content: value };
}
return { ok: false };
};
/** 判断值是否为 exact-shape 的已归一化 OneTalk 消息内容。 */
export const isOneTalkMessageContent = (value: unknown): value is OneTalkMessageContent => {
return decodeOneTalkMessageContent(value).ok;
};
/** 判断值是否为 exact-shape 的 Mind 公开消息读取模型。 */
export const isOneTalkCenterMessage = (value: unknown): value is OneTalkCenterMessage => {
if (!isRecord(value) || !hasExactKeys(value, CENTER_MESSAGE_KEYS)) return false;
return (
isNonBlankString(value.messageId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.conversationId, MAX_IDENTIFIER_LENGTH) &&
isNonBlankString(value.senderId, MAX_IDENTIFIER_LENGTH) &&
isOneTalkDirectParticipantSet(value.participantIds, value.senderId) &&
typeof value.direction === "string" &&
ONETALK_DIRECTIONS.includes(value.direction as OneTalkDirection) &&
isNonNegativeSafeInteger(value.sentAtMs) &&
typeof value.readStatus === "string" &&
ONETALK_CENTER_READ_STATUSES.includes(value.readStatus as OneTalkCenterReadStatus) &&
isOneTalkMessageContent(value.content)
);
};
+45 -21
View File
@@ -1,5 +1,10 @@
// 校验 OneTalk WebSocket 外部帧
import {
isOneTalkCenterMessage,
isOneTalkDirectParticipantSet,
isOneTalkMessageContent,
} from "./content.ts";
import {
ONETALK_CONNECTION_TYPES,
ONETALK_CONVERSATION_TYPES,
@@ -29,7 +34,6 @@ import {
type OneTalkFrameType,
type OneTalkJsonValue,
type OneTalkMessage,
type OneTalkObservedMessage,
type OneTalkSendResultStatus,
type OneTalkSendResultReason,
type OneTalkConnectionType,
@@ -58,6 +62,10 @@ const isFiniteNumber = (value: unknown): value is number => {
return typeof value === "number" && Number.isFinite(value);
};
const isNonNegativeSafeInteger = (value: unknown): value is number => {
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
};
const isNonEmptyString = (value: unknown): value is string => {
return typeof value === "string" && value.trim().length > 0;
};
@@ -232,28 +240,37 @@ const isOneTalkScopeForConnection = (
};
export const isOneTalkMessage = (value: unknown): value is OneTalkMessage => {
if (!isRecord(value)) return false;
if (
!isRecord(value) ||
!hasExactKeys(value, [
"messageId",
"conversationId",
"senderId",
"direction",
"sentAtMs",
"content",
"participantIds",
"readStatus",
"messageStatus",
"unreadCount",
])
) {
return false;
}
return (
isNonEmptyString(value.messageId) &&
isNonEmptyString(value.conversationId) &&
isNonEmptyString(value.senderId) &&
isOneTalkDirection(value.direction) &&
isFiniteNumber(value.sentAtMs) &&
isOneTalkJsonValue(value.content) &&
isFiniteNumber(value.contentType) &&
isStringArray(value.participantIds, isNonEmptyString) &&
isFiniteNumber(value.readStatus) &&
isFiniteNumber(value.messageStatus) &&
isFiniteNumber(value.unreadCount) &&
(value.text === undefined || value.text === null || typeof value.text === "string")
isNonNegativeSafeInteger(value.sentAtMs) &&
isOneTalkMessageContent(value.content) &&
isOneTalkDirectParticipantSet(value.participantIds, value.senderId) &&
isNonNegativeSafeInteger(value.readStatus) &&
isNonNegativeSafeInteger(value.messageStatus) &&
isNonNegativeSafeInteger(value.unreadCount)
);
};
const isOneTalkObservedMessage = (value: unknown): value is OneTalkObservedMessage => {
if (!isRecord(value)) return false;
return Object.values(value).every((item) => isOneTalkJsonValue(item));
};
const isOneTalkAnchor = (value: unknown): value is OneTalkAnchor => {
if (!isRecord(value) || !isNonEmptyString(value.conversationId)) return false;
return value.latestMessageId === null || isNonEmptyString(value.latestMessageId);
@@ -409,8 +426,9 @@ const isValidPayload = (
);
case "message.observed":
return (
hasExactKeys(value, ["observationSource", "message"]) &&
isOneTalkObservationSource(value.observationSource) &&
isOneTalkObservedMessage(value.message)
isOneTalkMessage(value.message)
);
case "message.ack":
return (
@@ -420,7 +438,7 @@ const isValidPayload = (
(value.anomalyCode === undefined || isNonEmptyString(value.anomalyCode))
);
case "message.created":
return isOneTalkMessage(value.message);
return hasExactKeys(value, ["message"]) && isOneTalkCenterMessage(value.message);
case "send.request":
return isNonEmptyString(value.conversationId) && isOneTalkJsonValue(value.content);
case "send.command":
@@ -429,21 +447,27 @@ const isValidPayload = (
return (
isOneTalkSendResultStatus(value.status) &&
(value.status === "confirmed_sent"
? value.message !== undefined &&
? hasExactKeys(value, ["status", "message"]) &&
value.message !== undefined &&
isOneTalkMessage(value.message) &&
value.message.direction === "sent" &&
value.reason === undefined
: value.message === undefined && isOneTalkSendResultReason(value.reason))
: hasExactKeys(value, ["status", "reason"]) &&
value.message === undefined &&
isOneTalkSendResultReason(value.reason))
);
case "send.result":
return (
isOneTalkSendResultStatus(value.status) &&
(value.status === "confirmed_sent"
? value.message !== undefined &&
? hasExactKeys(value, ["status", "message"]) &&
value.message !== undefined &&
isOneTalkMessage(value.message) &&
value.message.direction === "sent" &&
value.reason === undefined
: value.message === undefined && isOneTalkSendResultReason(value.reason))
: hasExactKeys(value, ["status", "reason"]) &&
value.message === undefined &&
isOneTalkSendResultReason(value.reason))
);
}
};
+1
View File
@@ -1,5 +1,6 @@
// 暴露 OneTalk 跨包公共协议边界
export * from "./authorization.ts";
export * from "./content.ts";
export * from "./decoder.ts";
export * from "./model.ts";
+7 -21
View File
@@ -1,6 +1,8 @@
// 定义 OneTalk 跨系统协议模型
export const ONETALK_PROTOCOL_VERSION = 2 as const;
import type { OneTalkCenterMessage, OneTalkMessageContent } from "./content.ts";
export const ONETALK_PROTOCOL_VERSION = 3 as const;
export const ONETALK_CONNECTION_TYPES = ["plugin", "mind_page"] as const;
export type OneTalkConnectionType = (typeof ONETALK_CONNECTION_TYPES)[number];
@@ -214,30 +216,14 @@ export type OneTalkMessage = {
senderId: string;
direction: OneTalkDirection;
sentAtMs: number;
content: OneTalkJsonValue;
contentType: number;
text?: string | null;
content: OneTalkMessageContent;
participantIds: string[];
readStatus: number;
messageStatus: number;
unreadCount: number;
};
export type OneTalkObservedMessage = {
[key: string]: OneTalkJsonValue | undefined;
messageId?: string | null;
conversationId?: string | null;
senderId?: string | null;
direction?: OneTalkDirection;
sentAtMs?: number;
content?: OneTalkJsonValue;
contentType?: number;
text?: string | null;
participantIds?: string[];
readStatus?: number;
messageStatus?: number;
unreadCount?: number;
};
export type OneTalkObservedMessage = OneTalkMessage;
export const ONETALK_CONTACT_PROFILE_STATUSES = ["confirmed", "partial"] as const;
export type OneTalkContactProfileObservationStatus =
@@ -452,7 +438,7 @@ export type OneTalkMessageAckFrame = OneTalkBaseFrame<
export type OneTalkMessageCreatedFrame = OneTalkBaseFrame<
"message.created",
{
message: OneTalkMessage;
message: OneTalkCenterMessage;
},
"mind_page"
>;
@@ -684,7 +670,7 @@ export const createOneTalkSendResultFrame = (
export const createOneTalkMessageCreatedFrame = (
frame: OneTalkFrameContext & { connectionType: "mind_page"; scope: OneTalkMindScope },
message: OneTalkMessage,
message: OneTalkCenterMessage,
): OneTalkMessageCreatedFrame => {
return {
protocolVersion: ONETALK_PROTOCOL_VERSION,
+315 -36
View File
@@ -5,7 +5,10 @@ import test from "node:test";
import {
ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES,
ONETALK_CONTENT_VERSION,
ONETALK_ERROR_CODES,
ONETALK_MAX_IMAGE_DIMENSION_PX,
ONETALK_MAX_MEDIA_SIZE_BYTES,
ONETALK_PROTOCOL_VERSION,
createMockAuthorizationReader,
createOneTalkAnchorSnapshotFrame,
@@ -15,12 +18,15 @@ import {
createOneTalkSyncStatusFrame,
decodeMindAuthorizationResponse,
decodeOneTalkFrame,
decodeOneTalkMessageContent,
isOneTalkAvatarUrl,
isOneTalkCenterMessage,
isOneTalkMessage,
isOneTalkMessageContent,
type OneTalkMindScope,
type MockAuthorizationRecord,
type OneTalkPluginScope,
type OneTalkFrame,
type OneTalkMessageObservedFrame,
} from "../src/index.ts";
const pluginScope: OneTalkPluginScope = {
@@ -41,24 +47,89 @@ const frameBase = {
scope: pluginScope,
};
const textContent = {
version: ONETALK_CONTENT_VERSION,
kind: "text" as const,
text: "hello",
};
const jpegContent = {
version: ONETALK_CONTENT_VERSION,
kind: "image" as const,
fileId: "image-jpeg-1",
extension: "jpg",
sizeBytes: 263_333,
width: 1_280,
height: 720,
isOriginal: true,
md5: "f28f1f8f4b760d5e2a89c3f0f83f3f68",
previewUrl:
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=onetalk&fileAction=imagePreview&id=image-jpeg-1&parentId=parent-1&scene=im&secOperateAliId=operation-1",
urlScope: "onetalk_session" as const,
};
const zipContent = {
version: ONETALK_CONTENT_VERSION,
kind: "file" as const,
fileId: "file-zip-1",
parentId: "parent-1",
fileName: "archive.zip",
extension: "zip",
sizeBytes: 8_192,
md5: "a614ee55b22a6545c2bc7342898c6a6f",
previewUrl: null,
thumbnailUrl:
"https://clouddisk.alibaba.com/file/videoThumb.htm?appkey=onetalk&id=file-zip-1&parentId=parent-1&scene=im&secOperateAliId=operation-2",
downloadUrl:
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=onetalk&fileAction=download&id=file-zip-1&parentId=parent-1&scene=im&secOperateAliId=operation-2",
downloadState: "available" as const,
urlScope: "onetalk_session" as const,
};
const pdfContent = {
version: ONETALK_CONTENT_VERSION,
kind: "file" as const,
fileId: "file-pdf-1",
parentId: "parent-1",
fileName: "quotation.pdf",
extension: "pdf",
sizeBytes: 64_512,
md5: "c56f2d90b0e701d6ffda5505b661b5d1",
previewUrl:
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=onetalk&fileAction=officePreview&id=file-pdf-1&parentId=parent-1&scene=im&secOperateAliId=operation-3",
thumbnailUrl:
"https://clouddisk.alibaba.com/file/videoThumb.htm?appkey=onetalk&id=file-pdf-1&parentId=parent-1&scene=im&secOperateAliId=operation-3",
downloadUrl: null,
downloadState: "not_provided" as const,
urlScope: "onetalk_session" as const,
};
const observedMessage = {
messageId: "message-1",
conversationId: "conversation-1",
senderId: "sender-1",
direction: "received" as const,
sentAtMs: 1_700_000_000_000,
content: { text: "hello" },
};
const completeMessage = {
...observedMessage,
contentType: 1,
content: textContent,
participantIds: ["sender-1", "login-user-1"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
};
const completeMessage = observedMessage;
const centerMessage = {
messageId: observedMessage.messageId,
conversationId: observedMessage.conversationId,
senderId: observedMessage.senderId,
participantIds: observedMessage.participantIds,
direction: observedMessage.direction,
sentAtMs: observedMessage.sentAtMs,
readStatus: "unread" as const,
content: textContent,
};
const authorizationRecord: MockAuthorizationRecord = {
scope: pluginScope,
mindScope,
@@ -102,18 +173,20 @@ test("rejects an unknown protocol version with the upgrade error", () => {
});
});
test("rejects the previous protocol version with the upgrade error", () => {
const result = decodeOneTalkFrame({
...frameBase,
protocolVersion: 1,
type: "ws.hello",
payload: { requestedPermissions: ["read"] },
});
test("hard-rejects v2 and other non-v3 protocol versions with the upgrade error", () => {
for (const protocolVersion of [0, 1, 2, 99]) {
const result = decodeOneTalkFrame({
...frameBase,
protocolVersion,
type: "ws.hello",
payload: { requestedPermissions: ["read"] },
});
assert.deepEqual(result, {
ok: false,
code: ONETALK_ERROR_CODES.protocolUpgradeRequired,
});
assert.deepEqual(result, {
ok: false,
code: ONETALK_ERROR_CODES.protocolUpgradeRequired,
});
}
});
test("rejects malformed scope and required send correlation", () => {
@@ -398,48 +471,254 @@ test("rejects a profile wire frame whose full serialized size exceeds the limit"
});
});
test("keeps legacy observed frames decodable while raw observations remain JSON objects", () => {
const legacy = decode({
test("requires exact normalized message observations and excludes raw message fields", () => {
const observed = decode({
...frameBase,
type: "message.observed",
payload: { observationSource: "history", message: observedMessage },
});
assert.deepEqual((legacy as OneTalkMessageObservedFrame).payload.message, observedMessage);
assert.equal(observed.type, "message.observed");
assert.deepEqual(observed.payload.message, observedMessage);
const malformedKnownField = decode({
for (const message of [
{ ...observedMessage, text: "legacy text" },
{ ...observedMessage, contentType: 1 },
{ ...observedMessage, content: { ...textContent, custom: { data: "raw-content" } } },
{ ...observedMessage, content: { ...textContent, version: 2 } },
{
...observedMessage,
content: { version: ONETALK_CONTENT_VERSION, kind: "text", text: "" },
},
{ ...observedMessage, sentAtMs: -1 },
]) {
const result = decodeOneTalkFrame({
...frameBase,
type: "message.observed",
payload: { observationSource: "incremental", message },
});
assert.deepEqual(result, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
assert.equal(JSON.stringify(result).includes("raw-content"), false);
}
const payloadWithRawField = decodeOneTalkFrame({
...frameBase,
type: "message.observed",
payload: {
observationSource: "incremental",
message: { messageId: "message-2", sentAtMs: "not-a-number" },
observationSource: "history",
message: observedMessage,
custom: { data: "raw-content" },
},
});
assert.equal(malformedKnownField.type, "message.observed");
const nonObject = decodeOneTalkFrame({
...frameBase,
type: "message.observed",
payload: { observationSource: "history", message: ["not-an-object"] },
assert.deepEqual(payloadWithRawField, {
ok: false,
code: ONETALK_ERROR_CODES.invalidMessage,
});
assert.deepEqual(nonObject, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
});
test("requires all persisted page fields for created and confirmed messages", () => {
test("validates normalized text, JPEG, ZIP, and PDF content with exact metadata", () => {
for (const content of [textContent, jpegContent, zipContent, pdfContent]) {
assert.equal(isOneTalkMessageContent(content), true);
assert.deepEqual(decodeOneTalkMessageContent(content), { ok: true, content });
}
for (const content of [
{ ...jpegContent, extension: "JPG" },
{ ...jpegContent, sizeBytes: -1 },
{ ...jpegContent, sizeBytes: Number.MAX_SAFE_INTEGER + 1 },
{
...jpegContent,
previewUrl: "https://example.test/file/redirectFileUrl.htm?fileAction=imagePreview",
},
{ ...jpegContent, previewUrl: "" },
{ ...zipContent, cardType: 12 },
{ ...zipContent, cardType: 2000 },
{ ...zipContent, fileName: "archive.pdf" },
{ ...zipContent, downloadUrl: null },
{ ...pdfContent, downloadState: "available" },
{ kind: "text", text: "missing-version" },
{ ...textContent, version: 2 },
{
...pdfContent,
previewUrl:
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?fileAction=download&id=file-pdf-1",
},
]) {
assert.equal(isOneTalkMessageContent(content), false);
assert.deepEqual(decodeOneTalkMessageContent(content), { ok: false });
}
const genericSendCommand = decode({
...frameBase,
type: "send.command",
sendRequestId: "send-generic-content",
payload: {
conversationId: "conversation-1",
content: { cardType: 2000, rawCommandOnly: true },
},
});
assert.equal(genericSendCommand.type, "send.command");
});
test("enforces media metadata bounds and legal nullable media URL states", () => {
for (const content of [
{ ...jpegContent, sizeBytes: 0, width: 0, height: 0, previewUrl: null },
{
...jpegContent,
sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES,
width: ONETALK_MAX_IMAGE_DIMENSION_PX,
height: ONETALK_MAX_IMAGE_DIMENSION_PX,
},
{ ...zipContent, sizeBytes: 0, thumbnailUrl: null },
{ ...zipContent, sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES, thumbnailUrl: null },
{
...pdfContent,
previewUrl: null,
thumbnailUrl: null,
downloadUrl: null,
downloadState: "not_provided" as const,
},
]) {
assert.equal(isOneTalkMessageContent(content), true);
}
for (const content of [
{ ...jpegContent, sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES + 1 },
{ ...zipContent, sizeBytes: ONETALK_MAX_MEDIA_SIZE_BYTES + 1 },
{ ...jpegContent, width: ONETALK_MAX_IMAGE_DIMENSION_PX + 1 },
{ ...jpegContent, height: ONETALK_MAX_IMAGE_DIMENSION_PX + 1 },
{ ...jpegContent, sizeBytes: Number.MAX_SAFE_INTEGER + 1 },
{ ...zipContent, sizeBytes: Number.MAX_SAFE_INTEGER + 1 },
{ ...jpegContent, width: Number.MAX_SAFE_INTEGER + 1 },
{ ...jpegContent, height: Number.MAX_SAFE_INTEGER + 1 },
]) {
assert.equal(isOneTalkMessageContent(content), false);
assert.deepEqual(decodeOneTalkMessageContent(content), { ok: false });
}
});
test("rejects unknown content kinds and invalid URL scopes", () => {
for (const content of [
{ version: ONETALK_CONTENT_VERSION, kind: "video", urlScope: "onetalk_session" },
{ ...jpegContent, urlScope: "public" },
{ ...zipContent, urlScope: "other_session" },
]) {
assert.equal(isOneTalkMessageContent(content), false);
assert.deepEqual(decodeOneTalkMessageContent(content), { ok: false });
}
});
test("requires exactly two unique direct participants including the sender", () => {
for (const participantIds of [[], ["sender-1"], ["sender-1", "sender-1"]]) {
const message = { ...completeMessage, participantIds };
assert.equal(isOneTalkMessage(message), false);
assert.deepEqual(
decodeOneTalkFrame({
...frameBase,
type: "message.observed",
payload: { observationSource: "history", message },
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
);
}
const senderOutsideSet = {
...completeMessage,
participantIds: ["other-1", "other-2"],
};
assert.equal(isOneTalkMessage(senderOutsideSet), false);
assert.deepEqual(
decodeOneTalkFrame({
...frameBase,
type: "message.observed",
payload: { observationSource: "history", message: senderOutsideSet },
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
);
for (const participantIds of [
["sender-1", "login-user-1"],
["login-user-1", "sender-1"],
]) {
const message = { ...completeMessage, participantIds };
assert.equal(isOneTalkMessage(message), true);
assert.equal(
decodeOneTalkFrame({
...frameBase,
type: "message.observed",
payload: { observationSource: "history", message },
}).ok,
true,
);
const center = { ...centerMessage, participantIds };
assert.equal(isOneTalkCenterMessage(center), true);
assert.equal(
decodeOneTalkFrame({
...frameBase,
connectionType: "mind_page" as const,
scope: mindScope,
type: "message.created",
payload: { message: center },
}).ok,
true,
);
}
for (const participantIds of [[], ["sender-1"], ["sender-1", "sender-1"]]) {
assert.equal(isOneTalkCenterMessage({ ...centerMessage, participantIds }), false);
}
assert.equal(
isOneTalkCenterMessage({
...centerMessage,
participantIds: ["other-1", "other-2"],
}),
false,
);
});
test("uses the shared public Center message for created frames and internal facts for confirmation", () => {
const created = decode({
...frameBase,
type: "message.created",
connectionType: "mind_page",
scope: mindScope,
payload: { message: centerMessage },
});
assert.equal(created.type, "message.created");
assert.equal(isOneTalkCenterMessage(centerMessage), true);
const internalMessageAsCreated = decodeOneTalkFrame({
...frameBase,
type: "message.created",
connectionType: "mind_page",
scope: mindScope,
payload: { message: completeMessage },
});
assert.equal(created.type, "message.created");
assert.deepEqual(internalMessageAsCreated, {
ok: false,
code: ONETALK_ERROR_CODES.invalidMessage,
});
const missingPageField = decodeOneTalkFrame({
const rawCenterMessage = decodeOneTalkFrame({
...frameBase,
type: "message.created",
payload: { message: observedMessage },
connectionType: "mind_page",
scope: mindScope,
payload: { message: { ...centerMessage, contentType: 1 } },
});
assert.deepEqual(rawCenterMessage, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
const legacyTextCenterMessage = decodeOneTalkFrame({
...frameBase,
type: "message.created",
connectionType: "mind_page",
scope: mindScope,
payload: { message: { ...centerMessage, text: "legacy text" } },
});
assert.deepEqual(legacyTextCenterMessage, {
ok: false,
code: ONETALK_ERROR_CODES.invalidMessage,
});
assert.deepEqual(missingPageField, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
const confirmed = decode({
...frameBase,
@@ -0,0 +1,8 @@
DELETE FROM "onetalk_message_anomaly";--> statement-breakpoint
DELETE FROM "onetalk_message";--> statement-breakpoint
DELETE FROM "onetalk_contact_profile";--> statement-breakpoint
DELETE FROM "onetalk_conversation";--> statement-breakpoint
ALTER TABLE "onetalk_message" DROP COLUMN "content_type";--> statement-breakpoint
ALTER TABLE "onetalk_message" DROP COLUMN "text";--> statement-breakpoint
ALTER TABLE "onetalk_message" ADD CONSTRAINT "onetalk_message_content_v1_chk" CHECK (jsonb_typeof("onetalk_message"."content") = 'object' and ("onetalk_message"."content" ->> 'version') = '1' and ("onetalk_message"."content" ->> 'kind') in ('text', 'image', 'file'));--> statement-breakpoint
COMMENT ON COLUMN "onetalk_message"."content" IS '唯一的 v1 规范化 text、image 或 file 消息内容 JSON;不保存 OneTalk raw envelope。';
+658
View File
@@ -0,0 +1,658 @@
{
"id": "f451de1d-303b-4faf-a14a-a4159b65850b",
"prevId": "5ec5c19a-12c4-4212-9f4c-465f0f5facd3",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.onetalk_contact_profile": {
"name": "onetalk_contact_profile",
"schema": "",
"columns": {
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"ali_id": {
"name": "ali_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"login_id": {
"name": "login_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"company_name": {
"name": "company_name",
"type": "text",
"primaryKey": false,
"notNull": false
},
"country_code": {
"name": "country_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"current_time_zone": {
"name": "current_time_zone",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"service_type": {
"name": "service_type",
"type": "text",
"primaryKey": false,
"notNull": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"observed_at_ms": {
"name": "observed_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"profile_fingerprint": {
"name": "profile_fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"observation_status": {
"name": "observation_status",
"type": "onetalk_contact_profile_observation_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"received_at": {
"name": "received_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_contact_profile_channel_account_id_conversation_id_pk": {
"name": "onetalk_contact_profile_channel_account_id_conversation_id_pk",
"columns": ["channel_account_id", "conversation_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.onetalk_conversation": {
"name": "onetalk_conversation",
"schema": "",
"columns": {
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"participant_ids": {
"name": "participant_ids",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"biz_type": {
"name": "biz_type",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"conversation_type": {
"name": "conversation_type",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"conversation_kind": {
"name": "conversation_kind",
"type": "text",
"primaryKey": false,
"notNull": false
},
"join_time_ms": {
"name": "join_time_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"modify_time_ms": {
"name": "modify_time_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"last_message_at_ms": {
"name": "last_message_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"sync_phase": {
"name": "sync_phase",
"type": "onetalk_sync_phase",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'initial'"
},
"sync_result": {
"name": "sync_result",
"type": "onetalk_sync_result",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'incomplete'"
},
"latest_message_id": {
"name": "latest_message_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"history_complete": {
"name": "history_complete",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"message_count": {
"name": "message_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"first_discovered_at": {
"name": "first_discovered_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_observed_at": {
"name": "last_observed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"anchor_updated_at": {
"name": "anchor_updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"onetalk_conversation_last_observed_idx": {
"name": "onetalk_conversation_last_observed_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "last_observed_at",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_conversation_channel_account_id_conversation_id_pk": {
"name": "onetalk_conversation_channel_account_id_conversation_id_pk",
"columns": ["channel_account_id", "conversation_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.onetalk_message": {
"name": "onetalk_message",
"schema": "",
"columns": {
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message_id": {
"name": "message_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"sender_id": {
"name": "sender_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"binding": {
"name": "binding",
"type": "text",
"primaryKey": false,
"notNull": true
},
"mind_user_id": {
"name": "mind_user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"direction": {
"name": "direction",
"type": "onetalk_message_direction",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"observation_type": {
"name": "observation_type",
"type": "onetalk_observation_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"sent_at_ms": {
"name": "sent_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"content": {
"name": "content",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"participant_ids": {
"name": "participant_ids",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"read_status": {
"name": "read_status",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"message_status": {
"name": "message_status",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"unread_count": {
"name": "unread_count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"first_observed_at": {
"name": "first_observed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_observed_at": {
"name": "last_observed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"onetalk_message_conversation_time_idx": {
"name": "onetalk_message_conversation_time_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "sent_at_ms",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "message_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"onetalk_message_sender_idx": {
"name": "onetalk_message_sender_idx",
"columns": [
{
"expression": "sender_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_message_channel_account_id_conversation_id_message_id_pk": {
"name": "onetalk_message_channel_account_id_conversation_id_message_id_pk",
"columns": ["channel_account_id", "conversation_id", "message_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"onetalk_message_content_v1_chk": {
"name": "onetalk_message_content_v1_chk",
"value": "jsonb_typeof(\"onetalk_message\".\"content\") = 'object' and (\"onetalk_message\".\"content\" ->> 'version') = '1' and (\"onetalk_message\".\"content\" ->> 'kind') in ('text', 'image', 'file')"
}
},
"isRLSEnabled": false
},
"public.onetalk_message_anomaly": {
"name": "onetalk_message_anomaly",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"fingerprint": {
"name": "fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"anomaly_type": {
"name": "anomaly_type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"binding": {
"name": "binding",
"type": "text",
"primaryKey": false,
"notNull": false
},
"mind_user_id": {
"name": "mind_user_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"workspace_id": {
"name": "workspace_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"channel_account_id": {
"name": "channel_account_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"device_id": {
"name": "device_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"conversation_id": {
"name": "conversation_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"missing_fields": {
"name": "missing_fields",
"type": "text[]",
"primaryKey": false,
"notNull": true
},
"observation_source": {
"name": "observation_source",
"type": "text",
"primaryKey": false,
"notNull": true
},
"payload": {
"name": "payload",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "onetalk_anomaly_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'open'"
},
"occurrence_count": {
"name": "occurrence_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1
},
"first_seen_at": {
"name": "first_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"last_seen_at": {
"name": "last_seen_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"resolved_at": {
"name": "resolved_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"onetalk_message_anomaly_fingerprint_uidx": {
"name": "onetalk_message_anomaly_fingerprint_uidx",
"columns": [
{
"expression": "fingerprint",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"onetalk_message_anomaly_scope_idx": {
"name": "onetalk_message_anomaly_scope_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "status",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.onetalk_anomaly_status": {
"name": "onetalk_anomaly_status",
"schema": "public",
"values": ["open", "resolved", "ignored"]
},
"public.onetalk_contact_profile_observation_status": {
"name": "onetalk_contact_profile_observation_status",
"schema": "public",
"values": ["confirmed", "partial"]
},
"public.onetalk_message_direction": {
"name": "onetalk_message_direction",
"schema": "public",
"values": ["sent", "received"]
},
"public.onetalk_observation_type": {
"name": "onetalk_observation_type",
"schema": "public",
"values": ["new", "history"]
},
"public.onetalk_sync_phase": {
"name": "onetalk_sync_phase",
"schema": "public",
"values": ["initial", "incremental"]
},
"public.onetalk_sync_result": {
"name": "onetalk_sync_result",
"schema": "public",
"values": ["succeeded", "succeeded_with_anomalies", "failed", "incomplete"]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+7
View File
@@ -36,6 +36,13 @@
"when": 1788379443696,
"tag": "0004_onetalk_conversation_direct_fact",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1788452420433,
"tag": "0005_young_squadron_supreme",
"breakpoints": true
}
]
}
+6 -4
View File
@@ -1,5 +1,7 @@
// Bright 侧 OneTalk 入口切换策略;状态由宿主注入并可持久化。
import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract";
export type OneTalkCutoverState = {
enabled: boolean;
paused: boolean;
@@ -8,7 +10,7 @@ export type OneTalkCutoverState = {
export type OneTalkCutoverListener = () => void;
export type OneTalkCutoverPolicy = {
canAdmit: (
mode: "bright-v2" | "legacy",
mode: "bright-v3" | "legacy",
connectionType: "plugin" | "mind_page",
protocolVersion: number,
) => boolean;
@@ -21,7 +23,7 @@ export type OneTalkCutoverPolicy = {
subscribe: (listener: OneTalkCutoverListener) => () => void;
};
/** 创建只控制 Bright v2 admission 和 monotonic epoch 的切换策略。 */
/** 创建只控制 Bright v3 admission 和 monotonic epoch 的切换策略。 */
export const createOneTalkCutoverPolicy = (
initial: OneTalkCutoverState = { enabled: true, paused: false },
): OneTalkCutoverPolicy => {
@@ -34,10 +36,10 @@ export const createOneTalkCutoverPolicy = (
};
return {
canAdmit: (mode, connectionType, protocolVersion) =>
mode === "bright-v2" &&
mode === "bright-v3" &&
state.enabled &&
!state.paused &&
protocolVersion === 2 &&
protocolVersion === ONETALK_PROTOCOL_VERSION &&
(connectionType === "plugin" || connectionType === "mind_page"),
snapshot: () => ({ ...state, epoch }),
pause: () => {
+9 -6
View File
@@ -1,8 +1,10 @@
// OneTalk 消息事实、技术会话同步与异常诊断表
import { sql } from "drizzle-orm";
import {
bigint,
boolean,
check,
doublePrecision,
index,
integer,
@@ -15,6 +17,7 @@ import {
uuid,
uniqueIndex,
} from "drizzle-orm/pg-core";
import type { OneTalkMessageContent } from "@trade-message-center/onetalk-contract";
export type JsonValue =
| null
@@ -74,12 +77,8 @@ export const onetalkMessage = pgTable(
observationType: onetalkObservationType("observation_type").notNull(),
/** OneTalk createAt,保留原始 epoch milliseconds。 */
sentAtMs: bigint("sent_at_ms", { mode: "number" }).notNull(),
/** OneTalk content.contentType。 */
contentType: integer("content_type").notNull(),
/** 从 OneTalk content.text.content 提取的文本,可为空。 */
text: text("text"),
/** 已规范化的内容 JSON;不得写入带认证信息的完整 envelope。 */
content: jsonb("content").$type<JsonValue>().notNull(),
/** 唯一内容事实:共享 contract 验证的 versioned normalized JSON。 */
content: jsonb("content").$type<OneTalkMessageContent>().notNull(),
/** 当前会话观察到的参与者 ID。 */
participantIds: text("participant_ids").array().notNull(),
/** OneTalk 消息 readStatus。 */
@@ -106,6 +105,10 @@ export const onetalkMessage = pgTable(
table.messageId,
),
index("onetalk_message_sender_idx").on(table.senderId),
check(
"onetalk_message_content_v1_chk",
sql`jsonb_typeof(${table.content}) = 'object' and (${table.content} ->> 'version') = '1' and (${table.content} ->> 'kind') in ('text', 'image', 'file')`,
),
],
);
+58 -23
View File
@@ -34,6 +34,12 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
#messages { list-style: none; padding: 0; margin: 0; display: grid; gap: 10px; }
#messages li { border: 1px solid #d9dee3; border-radius: 8px; padding: 10px; background: #fbfcfd; }
#messages pre { margin: 6px 0 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
.message-content { margin-top: 8px; }
.message-content p { margin: 0; overflow-wrap: anywhere; }
.message-meta { color: #68737d; font-size: 13px; margin-top: 5px !important; }
.message-image { display: block; max-width: min(100%, 560px); max-height: 420px; margin-top: 8px; border-radius: 6px; background: #eef1f4; }
.media-error { color: #9b1c1c; margin-top: 8px !important; }
.file-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 8px; }
.empty { color: #68737d; padding: 24px 8px; text-align: center; }
.hint { color: #68737d; font-size: 13px; }
</style>
@@ -147,6 +153,22 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
};
const isNullableString = (value) => value === null || typeof value === 'string';
const isNonBlankString = (value) => typeof value === 'string' && value.trim();
const isNonNegativeInteger = (value) => Number.isSafeInteger(value) && value >= 0;
const isNormalizedContent = (value) => {
if (!isRecord(value)) return false;
if (value.kind === 'text') return hasExactKeys(value, ['kind', 'text', 'version']) && value.version === 1 && isNonBlankString(value.text);
if (value.kind === 'image') return hasExactKeys(value, ['extension', 'fileId', 'height', 'isOriginal', 'kind', 'md5', 'previewUrl', 'sizeBytes', 'urlScope', 'version', 'width'])
&& value.version === 1 && isNonBlankString(value.fileId) && isNonBlankString(value.extension)
&& isNonNegativeInteger(value.sizeBytes) && isNonNegativeInteger(value.width) && isNonNegativeInteger(value.height)
&& typeof value.isOriginal === 'boolean' && isNullableString(value.md5) && isNullableString(value.previewUrl) && value.urlScope === 'onetalk_session';
if (value.kind === 'file') return hasExactKeys(value, ['downloadState', 'downloadUrl', 'extension', 'fileId', 'fileName', 'kind', 'md5', 'parentId', 'previewUrl', 'sizeBytes', 'thumbnailUrl', 'urlScope', 'version'])
&& value.version === 1 && isNonBlankString(value.fileId) && isNonBlankString(value.parentId) && isNonBlankString(value.fileName) && isNonBlankString(value.extension)
&& isNonNegativeInteger(value.sizeBytes) && isNullableString(value.md5) && isNullableString(value.previewUrl)
&& isNullableString(value.thumbnailUrl) && isNullableString(value.downloadUrl)
&& (value.downloadState === 'available' || value.downloadState === 'not_provided') && value.urlScope === 'onetalk_session';
return false;
};
const isCenterMessageBase = (value) => isRecord(value)
&& typeof value.messageId === 'string' && value.messageId.trim()
&& typeof value.conversationId === 'string' && value.conversationId.trim()
@@ -157,17 +179,11 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
&& value.participantIds.every((id) => typeof id === 'string' && id.trim())
&& (value.readStatus === 'read' || value.readStatus === 'unread');
const isCenterMessage = (value) => {
if (!isCenterMessageBase(value) || !hasExactKeys(value, ['content', 'contentType', 'conversationId', 'direction', 'messageId', 'participantIds', 'readStatus', 'senderId', 'sentAtMs'])) return false;
if (value.contentType === 'text') return isRecord(value.content) && hasExactKeys(value.content, ['text']) && isNullableString(value.content.text);
if (value.contentType === 'img') return isRecord(value.content) && hasExactKeys(value.content, ['url']) && isNullableString(value.content.url);
if (value.contentType === 'attachment') return isRecord(value.content) && hasExactKeys(value.content, ['name', 'url']) && isNullableString(value.content.name) && isNullableString(value.content.url);
return value.contentType === 'unknown' && value.content === null;
return isCenterMessageBase(value)
&& hasExactKeys(value, ['content', 'conversationId', 'direction', 'messageId', 'participantIds', 'readStatus', 'senderId', 'sentAtMs'])
&& isNormalizedContent(value.content);
};
// WebSocket message.created remains on the existing protocol boundary;
// HTTP history is always checked with isCenterMessage above.
const isLiveMessage = (value) => isRecord(value) && typeof value.messageId === 'string' && value.messageId.trim() && typeof value.conversationId === 'string' && value.conversationId.trim() && typeof value.senderId === 'string' && value.senderId.trim() && (value.direction === 'received' || value.direction === 'sent') && Number.isSafeInteger(value.sentAtMs) && value.content !== undefined && Number.isSafeInteger(value.contentType) && Array.isArray(value.participantIds) && value.participantIds.every((id) => typeof id === 'string' && id.trim()) && Number.isSafeInteger(value.readStatus) && Number.isSafeInteger(value.messageStatus) && Number.isSafeInteger(value.unreadCount);
const isConversation = (value) => isRecord(value)
&& hasExactKeys(value, ['avatarUrl', 'channelAccountId', 'conversationId', 'conversationType', 'historyComplete', 'latestMessageAtMs', 'latestMessageId', 'messageCount', 'name', 'participantIds', 'syncPhase', 'syncResult', 'unreadCount'])
&& typeof value.channelAccountId === 'string' && value.channelAccountId.trim()
@@ -264,6 +280,36 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
const escapeHtml = (value) => String(value).replace(/[&<>'"]/g, (character) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;' }[character]));
const formatSize = (sizeBytes) => sizeBytes >= 1024 * 1024
? (sizeBytes / (1024 * 1024)).toFixed(1) + ' MB'
: sizeBytes >= 1024
? (sizeBytes / 1024).toFixed(1) + ' KB'
: sizeBytes + ' B';
const renderLink = (url, label) => url
? '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" referrerpolicy="no-referrer">' + escapeHtml(label) + '</a>'
: '';
const renderContent = (content) => {
if (content.kind === 'text') {
return '<div class="message-content"><p>' + escapeHtml(content.text) + '</p></div>';
}
if (content.kind === 'image') {
const metadata = escapeHtml(content.extension + ' · ' + content.width + ' × ' + content.height + ' · ' + formatSize(content.sizeBytes));
const preview = content.previewUrl
? '<img class="message-image" src="' + escapeHtml(content.previewUrl) + '" alt="OneTalk 图片预览" onerror="this.hidden=true;this.nextElementSibling.hidden=false"><p class="media-error" hidden>图片预览加载失败;可稍后刷新历史。</p>'
: '<p class="media-error">图片未提供预览地址。</p>';
return '<div class="message-content"><p>图片</p><p class="message-meta">' + metadata + '</p>' + preview + '</div>';
}
const links = [
renderLink(content.previewUrl, '预览'),
renderLink(content.thumbnailUrl, '缩略图'),
renderLink(content.downloadUrl, '打开下载地址')
].filter(Boolean).join('');
const action = links ? '<div class="file-actions">' + links + '</div>' : '<p class="media-error">文件未提供可用链接。</p>';
return '<div class="message-content"><p>' + escapeHtml(content.fileName) + '</p><p class="message-meta">' + escapeHtml(content.extension + ' · ' + formatSize(content.sizeBytes) + ' · ' + content.downloadState) + '</p>' + action + '</div>';
};
const renderMessages = () => {
if (state.messages.size === 0) {
fields.messages.hidden = true;
@@ -275,17 +321,8 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
fields.messageEmpty.hidden = true;
fields.messages.innerHTML = Array.from(state.messages.values()).map((message) => {
const sentAt = Math.abs(message.sentAtMs) <= 8640000000000000 ? new Date(message.sentAtMs).toISOString() : 'invalid sentAtMs';
const raw = JSON.stringify({
scope: currentScope(),
conversationId: message.conversationId,
messageId: message.messageId,
senderId: message.senderId,
direction: message.direction,
sentAtMs: message.sentAtMs,
sentAt,
content: message.content
}, null, 2);
return '<li><strong>' + escapeHtml(message.direction) + ' · ' + escapeHtml(sentAt) + '</strong><pre>' + escapeHtml(raw) + '</pre></li>';
const normalized = JSON.stringify(message.content, null, 2);
return '<li><strong>' + escapeHtml(message.direction) + ' · ' + escapeHtml(sentAt) + '</strong>' + renderContent(message.content) + '<pre>' + escapeHtml(normalized) + '</pre></li>';
}).join('');
};
@@ -402,7 +439,7 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
let frame = null;
try { frame = JSON.parse(event.data); } catch (_) { return; }
if (!isRecord(frame) || !isScope(frame.scope) || !isRecord(frame.payload)) return;
if (frame.type === 'message.created' && isLiveMessage(frame.payload.message)) addMessage(frame.payload.message, frame.scope);
if (frame.type === 'message.created' && isCenterMessage(frame.payload.message)) addMessage(frame.payload.message, frame.scope);
if (frame.type === 'plugin.status' && matchesCurrentScope(frame.scope) && isPlugin(frame.payload)) setPluginStatus(frame.payload.status);
if (frame.type === 'sync.status' && matchesCurrentScope(frame.scope) && isSyncStatus(frame.payload)) {
const payload = frame.payload;
@@ -412,12 +449,10 @@ export const ONE_TALK_HARNESS_HTML = String.raw`<!doctype html>
if (frame.type === 'send.result' && frame.sendRequestId && frame.sendRequestId === state.pendingSendRequestId && activeScope && isScope(frame.scope) && frame.scope.mindUserId === activeScope.mindUserId && frame.scope.workspaceId === activeScope.workspaceId && frame.scope.channelAccountId === activeScope.channelAccountId) {
const status = frame.payload && frame.payload.status;
if (!['confirmed_sent', 'rejected_before_send', 'delivery_unknown'].includes(status)) return;
if (status === 'confirmed_sent' && (!isLiveMessage(frame.payload.message) || frame.payload.message.direction !== 'sent')) return;
state.pendingSendRequestId = null;
updateSendAvailability();
const text = status === 'confirmed_sent' ? '已确认发送并入库' : status === 'rejected_before_send' ? '发送前拒绝' : '结果未知(不会自动重试)';
setStatus(fields.syncStatus, status + ' · ' + text + (frame.payload.reason ? ' · ' + frame.payload.reason : ''), status === 'confirmed_sent' ? 'ok' : status === 'rejected_before_send' ? 'warn' : 'error');
if (status === 'confirmed_sent' && isLiveMessage(frame.payload.message)) addMessage(frame.payload.message, frame.scope);
}
if (frame.type === 'ws.error' && frame.payload) {
setStatus(fields.connectionStatus, errorText({ code: frame.payload.code }), 'error');
+4 -3
View File
@@ -1,6 +1,7 @@
// 提供 Bright 受权会话读取 HTTP 边界
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract";
import type {
OneTalkAuthorizationDecision,
OneTalkAuthorizationReader,
@@ -281,11 +282,11 @@ export const installOneTalkReadRoutes = (
return (
epoch !== undefined &&
options.cutoverPolicy.isCurrent(epoch) &&
options.cutoverPolicy.canAdmit("bright-v2", "mind_page", 2)
options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)
);
};
const mindOriginGuard = async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (!options.cutoverPolicy.canAdmit("bright-v2", "mind_page", 2)) {
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
return void sendError(reply, 503, "authorization_unavailable");
}
requestEpochs.set(request, options.cutoverPolicy.capture());
@@ -293,7 +294,7 @@ export const installOneTalkReadRoutes = (
applyCors(reply, request, options.mindPageOrigin);
};
app.options("/api/bright/onetalk/*", async (request, reply) => {
if (!options.cutoverPolicy.canAdmit("bright-v2", "mind_page", 2)) {
if (!options.cutoverPolicy.canAdmit("bright-v3", "mind_page", ONETALK_PROTOCOL_VERSION)) {
return sendError(reply, 503, "authorization_unavailable");
}
if (
-1
View File
@@ -53,7 +53,6 @@ export type {
OneTalkListCursor,
OneTalkReadConversationQuery,
OneTalkReadConversationRow,
OneTalkReadDiagnostic,
OneTalkReadHistoryQuery,
OneTalkReadListQuery,
OneTalkReadMessageRow,
+5 -26
View File
@@ -1,7 +1,8 @@
// 定义 OneTalk 会话读取领域契约
import type {
OneTalkJsonValue,
OneTalkCenterMessage,
OneTalkMessageContent,
OneTalkMindScope,
OneTalkSyncResult,
} from "@trade-message-center/onetalk-contract";
@@ -25,24 +26,8 @@ export type CenterConversation = {
syncResult: OneTalkSyncResult;
};
type CenterMessageBase = {
messageId: string;
conversationId: string;
senderId: string;
participantIds: string[];
direction: "received" | "sent";
sentAtMs: number;
readStatus: "read" | "unread";
};
export type CenterMessage =
| (CenterMessageBase & { contentType: "text"; content: { text: string | null } })
| (CenterMessageBase & { contentType: "img"; content: { url: string | null } })
| (CenterMessageBase & {
contentType: "attachment";
content: { name: string | null; url: string | null };
})
| (CenterMessageBase & { contentType: "unknown"; content: null });
/** Deprecated local alias retained only to avoid a second public message type. */
export type CenterMessage = OneTalkCenterMessage;
export type OneTalkReadConversationRow = {
channelAccountId: string;
@@ -64,8 +49,7 @@ export type OneTalkReadMessageRow = {
senderId: string;
direction: "received" | "sent";
sentAtMs: number;
contentType: number;
content: OneTalkJsonValue;
content: OneTalkMessageContent;
participantIds: string[];
readStatus: number;
};
@@ -124,10 +108,6 @@ export type OneTalkReadRepository = {
listMessages: (query: OneTalkReadHistoryQuery) => Promise<OneTalkReadMessageRow[]>;
};
export type OneTalkReadDiagnostic = {
code: "unknown_read_status";
};
export type OneTalkConversationListInput = {
scope: OneTalkMindScope;
query?: string | null;
@@ -188,5 +168,4 @@ export type OneTalkReadService = {
export type OneTalkReadServiceDependencies = {
now?: () => Date;
onDiagnostic?: (diagnostic: OneTalkReadDiagnostic) => void;
};
+37 -201
View File
@@ -1,154 +1,17 @@
// 投影安全的 OneTalk 中心读取响应
// 投影已验证的 OneTalk 中心读取响应
import { Buffer, isUtf8 } from "node:buffer";
import type { OneTalkJsonValue } from "@trade-message-center/onetalk-contract";
import {
isOneTalkMessageContent,
type OneTalkCenterMessage,
type OneTalkMessage,
} from "@trade-message-center/onetalk-contract";
import type {
CenterConversation,
CenterMessage,
OneTalkReadConversationRow,
OneTalkReadDiagnostic,
OneTalkReadMessageRow,
} from "./read-model.ts";
type ProjectedMessage = {
message: CenterMessage;
diagnostic: OneTalkReadDiagnostic | null;
};
const ONETALK_TEXT_CONTENT_TYPE = 1;
const ONETALK_MEDIA_CONTENT_TYPE = 101;
const ONETALK_IMAGE_CUSTOM_TYPE = 7;
const ONETALK_ATTACHMENT_CUSTOM_TYPE = 10010;
const isRecord = (value: unknown): value is Record<string, OneTalkJsonValue> => {
return typeof value === "object" && value !== null && !Array.isArray(value);
};
const isSafeInteger = (value: OneTalkJsonValue): value is number => {
return typeof value === "number" && Number.isSafeInteger(value);
};
const isNonEmptyString = (value: OneTalkJsonValue): value is string => {
return typeof value === "string" && value.length > 0;
};
const isNonNegativeNumber = (value: OneTalkJsonValue): value is number => {
return typeof value === "number" && Number.isFinite(value) && value >= 0;
};
const isBase64 = (value: string): boolean => {
if (value.length === 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(value)) return false;
const paddingLength = value.length - value.replace(/=+$/, "").length;
const bodyLength = value.length - paddingLength;
if (bodyLength % 4 === 1) return false;
if (paddingLength === 0) return true;
if (value.length % 4 !== 0) return false;
return (
(paddingLength === 1 && bodyLength % 4 === 3) ||
(paddingLength === 2 && bodyLength % 4 === 2)
);
};
const parseBase64JsonObject = (
value: OneTalkJsonValue,
): Record<string, OneTalkJsonValue> | null => {
if (typeof value !== "string" || !isBase64(value)) return null;
const bytes = Buffer.from(value, "base64");
if (!isUtf8(bytes)) return null;
try {
const parsed: unknown = JSON.parse(bytes.toString("utf8"));
return isRecord(parsed) ? parsed : null;
} catch {
return null;
}
};
const safeUrlFor = (value: OneTalkJsonValue): string | null => {
if (!isNonEmptyString(value) || value.trim() !== value) return null;
try {
const url = new URL(value);
if (url.protocol !== "http:" && url.protocol !== "https:") return null;
if (url.username || url.password) return null;
return value;
} catch {
return null;
}
};
const optionalSafeUrlFor = (value: OneTalkJsonValue | undefined): string | null => {
if (value === "") return null;
return typeof value === "string" ? safeUrlFor(value) : null;
};
const imageUrlFor = (metadata: Record<string, OneTalkJsonValue>): string | null => {
if (
!isNonEmptyString(metadata.fileId) ||
!isNonNegativeNumber(metadata.width) ||
!isNonNegativeNumber(metadata.height) ||
!isNonNegativeNumber(metadata.size) ||
!isNonEmptyString(metadata.suffix) ||
!isNonEmptyString(metadata.md5) ||
typeof metadata.isOriginal !== "boolean"
) {
return null;
}
return safeUrlFor(metadata.url);
};
const attachmentUrlFor = (metadata: Record<string, OneTalkJsonValue>): string | null => {
if (
!isRecord(metadata.params) ||
!isNonEmptyString(metadata.params.name) ||
!isNonNegativeNumber(metadata.size) ||
(!isNonEmptyString(metadata.type) && !isSafeInteger(metadata.type)) ||
!isNonEmptyString(metadata.extensionType) ||
!isNonEmptyString(metadata.md5)
) {
return null;
}
const downloadUrl = optionalSafeUrlFor(metadata.downloadUrl);
const url = optionalSafeUrlFor(metadata.url);
const thumbnailUrl = optionalSafeUrlFor(metadata.thumbnailUrl);
// Optional URLs can be omitted, empty, or invalid; retain the verified fallback order.
return downloadUrl ?? url ?? thumbnailUrl;
};
const hasTextContent = (value: OneTalkJsonValue): value is { text: { content: string | null } } => {
if (!isRecord(value) || !isRecord(value.text)) return false;
const text = value.text.content;
return typeof text === "string" || text === null;
};
const hasVerifiedTextContent = (
row: OneTalkReadMessageRow,
): row is OneTalkReadMessageRow & { content: { text: { content: string | null } } } => {
return (
row.contentType === ONETALK_TEXT_CONTENT_TYPE &&
customTypeFor(row.content) === null &&
hasTextContent(row.content)
);
};
type CustomType = number | null | "invalid";
const customTypeFor = (value: OneTalkJsonValue): CustomType => {
if (!isRecord(value) || !Object.hasOwn(value, "custom")) return null;
if (!isRecord(value.custom)) return "invalid";
if (!isSafeInteger(value.custom.type)) return "invalid";
return value.custom.type;
};
const mediaMetadataFor = (
content: OneTalkJsonValue,
customType: number,
): Record<string, OneTalkJsonValue> | null => {
if (!isRecord(content) || !isRecord(content.custom) || content.custom.type !== customType)
return null;
return parseBase64JsonObject(content.custom.data);
};
/** 投影公开会话,不暴露同步锚点或原始会话元数据。 */
export const toCenterConversation = (row: OneTalkReadConversationRow): CenterConversation => {
return {
@@ -168,63 +31,36 @@ export const toCenterConversation = (row: OneTalkReadConversationRow): CenterCon
};
};
/** 投影公开消息,并将未知 readStatus 转为不含正文的诊断。 */
export const projectCenterMessage = (row: OneTalkReadMessageRow): ProjectedMessage => {
const diagnostic =
row.readStatus === 1
? null
: {
code: "unknown_read_status" as const,
};
const base = {
messageId: row.messageId,
conversationId: row.conversationId,
senderId: row.senderId,
participantIds: [...row.participantIds],
direction: row.direction,
sentAtMs: row.sentAtMs,
readStatus: row.readStatus === 1 ? ("read" as const) : ("unread" as const),
type OneTalkCenterMessageInput = Pick<
OneTalkMessage,
| "messageId"
| "conversationId"
| "senderId"
| "participantIds"
| "direction"
| "sentAtMs"
| "readStatus"
| "content"
>;
/** 从同一 normalized JSONB 事实投影 Mind-facing 消息;不重新解释 raw content。 */
export const toOneTalkCenterMessage = (
message: OneTalkCenterMessageInput,
): OneTalkCenterMessage => {
if (!isOneTalkMessageContent(message.content)) {
throw new Error("Invalid persisted OneTalk message content");
}
return {
messageId: message.messageId,
conversationId: message.conversationId,
senderId: message.senderId,
participantIds: [...message.participantIds],
direction: message.direction,
sentAtMs: message.sentAtMs,
readStatus: message.readStatus === 1 ? "read" : "unread",
content: { ...message.content },
};
if (hasVerifiedTextContent(row)) {
return {
message: {
...base,
contentType: "text",
content: { text: row.content.text.content },
},
diagnostic,
};
}
if (
row.contentType === ONETALK_MEDIA_CONTENT_TYPE &&
customTypeFor(row.content) === ONETALK_IMAGE_CUSTOM_TYPE
) {
const metadata = mediaMetadataFor(row.content, ONETALK_IMAGE_CUSTOM_TYPE);
const imageUrl = metadata ? imageUrlFor(metadata) : null;
if (imageUrl) {
return {
message: { ...base, contentType: "img", content: { url: imageUrl } },
diagnostic,
};
}
}
if (
row.contentType === ONETALK_MEDIA_CONTENT_TYPE &&
customTypeFor(row.content) === ONETALK_ATTACHMENT_CUSTOM_TYPE
) {
const metadata = mediaMetadataFor(row.content, ONETALK_ATTACHMENT_CUSTOM_TYPE);
const url = metadata ? attachmentUrlFor(metadata) : null;
const params = metadata?.params;
if (url && isRecord(params) && isNonEmptyString(params.name)) {
return {
message: {
...base,
contentType: "attachment",
content: { name: params.name, url },
},
diagnostic,
};
}
}
return { message: { ...base, contentType: "unknown", content: null }, diagnostic };
};
export const projectCenterMessage = (row: OneTalkReadMessageRow): OneTalkCenterMessage =>
toOneTalkCenterMessage(row);
+1 -3
View File
@@ -1,7 +1,6 @@
// 查询 OneTalk 已持久化的读取快照
import { and, asc, desc, eq, gt, gte, isNull, lt, lte, or, sql } from "drizzle-orm";
import type { OneTalkJsonValue } from "@trade-message-center/onetalk-contract";
import type { Database } from "../database/index.ts";
import {
@@ -122,8 +121,7 @@ const toReadMessage = (row: MessageRow): OneTalkReadMessageRow => {
senderId: row.senderId,
direction: row.direction,
sentAtMs: row.sentAtMs,
contentType: row.contentType,
content: row.content as OneTalkJsonValue,
content: { ...row.content },
participantIds: [...row.participantIds],
readStatus: row.readStatus,
};
+1 -19
View File
@@ -16,7 +16,6 @@ import {
type OneTalkHistoryReadInput,
type OneTalkHistoryReadResult,
type OneTalkHistoryWindow,
type OneTalkReadDiagnostic,
type OneTalkReadRepository,
type OneTalkReadService,
type OneTalkReadServiceDependencies,
@@ -82,18 +81,6 @@ const historyCursorMatches = (
);
};
const notifyDiagnostic = (
onDiagnostic: OneTalkReadServiceDependencies["onDiagnostic"],
diagnostic: OneTalkReadDiagnostic,
): void => {
if (!onDiagnostic) return;
try {
onDiagnostic(diagnostic);
} catch {
// Diagnostics are non-blocking observations and cannot fail a completed read.
}
};
/** 创建公开读取的唯一领域投影入口。 */
export const createOneTalkReadService = (
repository: OneTalkReadRepository,
@@ -209,12 +196,7 @@ export const createOneTalkReadService = (
const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const cursorRow = pageRows.at(-1);
const messages = [...pageRows].reverse().map((row) => {
const projected = projectCenterMessage(row);
if (projected.diagnostic)
notifyDiagnostic(dependencies.onDiagnostic, projected.diagnostic);
return projected.message;
});
const messages = [...pageRows].reverse().map(projectCenterMessage);
return {
status: "accepted",
conversationId: input.conversationId,
+2 -6
View File
@@ -41,9 +41,7 @@ const toMessage = (row: MessageRow): OneTalkMessage => {
senderId: row.senderId,
direction: row.direction,
sentAtMs: row.sentAtMs,
content: row.content as OneTalkJsonValue,
contentType: row.contentType,
text: row.text,
content: { ...row.content },
participantIds: [...row.participantIds],
readStatus: row.readStatus,
messageStatus: row.messageStatus,
@@ -111,9 +109,7 @@ const messageValues = (
direction: message.direction,
observationType: observationTypeFor(observationSource),
sentAtMs: message.sentAtMs,
contentType: message.contentType,
text: message.text ?? null,
content: toJsonValue(message.content),
content: { ...message.content },
participantIds: [...message.participantIds],
readStatus: message.readStatus,
messageStatus: message.messageStatus,
+16 -165
View File
@@ -9,6 +9,7 @@ import type {
OneTalkObservedMessage,
OneTalkObservationSource,
} from "@trade-message-center/onetalk-contract";
import { isOneTalkMessage } from "@trade-message-center/onetalk-contract";
import {
type OneTalkAnomalyInput,
@@ -33,8 +34,6 @@ const MESSAGE_FIELDS = [
"direction",
"sentAtMs",
"content",
"contentType",
"text",
"participantIds",
"readStatus",
"messageStatus",
@@ -43,116 +42,23 @@ const MESSAGE_FIELDS = [
type MessageField = (typeof MESSAGE_FIELDS)[number];
const SENSITIVE_KEY_PATTERN =
/(token|cookie|csrf|authorization|secret|password|credential|sid|app-key)/i;
const isRecord = (value: unknown): value is Record<string, unknown> => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
const isNonEmptyString = (value: unknown): value is string => {
return typeof value === "string" && value.trim().length > 0;
};
const isFiniteInteger = (value: unknown): value is number => {
return typeof value === "number" && Number.isSafeInteger(value);
};
const isJsonValue = (
value: unknown,
ancestors = new WeakSet<object>(),
): value is OneTalkJsonValue => {
if (value === null) return true;
if (typeof value === "string" || typeof value === "boolean") return true;
if (typeof value === "number") return Number.isFinite(value);
if (typeof value !== "object" || ancestors.has(value)) return false;
ancestors.add(value);
try {
if (Array.isArray(value)) return value.every((item) => isJsonValue(item, ancestors));
if (!isRecord(value)) return false;
return Object.values(value).every((item) => isJsonValue(item, ancestors));
} finally {
ancestors.delete(value);
}
};
const sanitizeJsonValue = (
value: unknown,
key: string | undefined = undefined,
): OneTalkJsonValue | undefined => {
if (key !== undefined && SENSITIVE_KEY_PATTERN.test(key)) return undefined;
if (value === null) return null;
if (typeof value === "string" || typeof value === "boolean") return value;
if (typeof value === "number" && Number.isFinite(value)) return value;
if (Array.isArray(value)) {
return value.flatMap((item) => {
const sanitized = sanitizeJsonValue(item);
return sanitized === undefined ? [] : [sanitized];
});
}
if (!isRecord(value)) return null;
const result: { [key: string]: OneTalkJsonValue } = {};
for (const [entryKey, entryValue] of Object.entries(value)) {
const sanitized = sanitizeJsonValue(entryValue, entryKey);
if (sanitized !== undefined) result[entryKey] = sanitized;
}
return result;
};
const valueType = (value: unknown): string => {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
};
const toSnakeCase = (field: string): string => {
return field.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
};
const anomalyCodeFor = (fields: MessageField[]): string => {
return fields.length === 1
? `invalid_${toSnakeCase(fields[0])}`
: "invalid_message_observation";
};
const anomalyPayloadFor = (rawMessage: OneTalkObservedMessage): OneTalkJsonValue => {
const fields: { [key: string]: OneTalkJsonValue } = {};
for (const field of MESSAGE_FIELDS) {
fields[field] = {
present: rawMessage[field] !== undefined,
type: valueType(rawMessage[field]),
};
}
const keys = Object.keys(rawMessage)
.filter((key) => !SENSITIVE_KEY_PATTERN.test(key))
.sort();
return { fields, keys };
};
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
const anomalyForMessage = (
context: OneTalkSourceContext,
observationSource: OneTalkObservationSource,
rawMessage: OneTalkObservedMessage,
invalidFields: MessageField[],
invalidFields: readonly MessageField[],
): OneTalkAnomalyInput => {
const conversationId = isNonEmptyString(rawMessage.conversationId)
? rawMessage.conversationId
: undefined;
const anomalyCode = anomalyCodeFor(invalidFields);
const payload = anomalyPayloadFor(rawMessage);
const anomalyCode = "invalid_message_observation";
const payload: OneTalkJsonValue = { fields: [...invalidFields] };
const fingerprint = createHash("sha256")
.update(
JSON.stringify({
anomalyCode,
context,
conversationId,
invalidFields,
observationSource,
payload,
}),
)
.digest("hex");
@@ -161,7 +67,7 @@ const anomalyForMessage = (
anomalyType: "message_field_invalid",
anomalyCode,
context,
conversationId,
conversationId: undefined,
missingFields: [...invalidFields],
observationSource,
payload,
@@ -173,75 +79,20 @@ const normalizeMessage = (
observationSource: OneTalkObservationSource,
rawMessage: OneTalkObservedMessage,
): OneTalkMessageNormalization => {
const invalidFields: MessageField[] = [];
if (!isNonEmptyString(rawMessage.messageId)) invalidFields.push("messageId");
if (!isNonEmptyString(rawMessage.conversationId)) invalidFields.push("conversationId");
if (!isNonEmptyString(rawMessage.senderId)) invalidFields.push("senderId");
if (rawMessage.direction !== "sent" && rawMessage.direction !== "received") {
invalidFields.push("direction");
}
if (!isFiniteInteger(rawMessage.sentAtMs)) invalidFields.push("sentAtMs");
if (!isJsonValue(rawMessage.content)) invalidFields.push("content");
if (!isFiniteInteger(rawMessage.contentType)) invalidFields.push("contentType");
if (
!Array.isArray(rawMessage.participantIds) ||
!rawMessage.participantIds.every((participantId) => isNonEmptyString(participantId))
) {
invalidFields.push("participantIds");
}
if (!isFiniteInteger(rawMessage.readStatus)) invalidFields.push("readStatus");
if (!isFiniteInteger(rawMessage.messageStatus)) invalidFields.push("messageStatus");
if (!isFiniteInteger(rawMessage.unreadCount)) invalidFields.push("unreadCount");
if (
rawMessage.text !== undefined &&
rawMessage.text !== null &&
typeof rawMessage.text !== "string"
) {
invalidFields.push("text");
}
if (invalidFields.length > 0) {
if (!isOneTalkMessage(rawMessage)) {
return {
ok: false,
anomaly: anomalyForMessage(context, observationSource, rawMessage, invalidFields),
anomaly: anomalyForMessage(context, observationSource, ["content"]),
};
}
const sanitizedContent = sanitizeJsonValue(rawMessage.content);
if (sanitizedContent === undefined) {
return {
ok: false,
anomaly: anomalyForMessage(context, observationSource, rawMessage, ["content"]),
};
}
// 只有前面的门槛检查通过后,raw transport 字段才可收窄为持久化类型。
const messageId = rawMessage.messageId as string;
const conversationId = rawMessage.conversationId as string;
const senderId = rawMessage.senderId as string;
const direction = rawMessage.direction as OneTalkMessage["direction"];
const sentAtMs = rawMessage.sentAtMs as number;
const contentType = rawMessage.contentType as number;
const participantIds = rawMessage.participantIds as string[];
const readStatus = rawMessage.readStatus as number;
const messageStatus = rawMessage.messageStatus as number;
const unreadCount = rawMessage.unreadCount as number;
const message: OneTalkMessage = {
messageId,
conversationId,
senderId,
direction,
sentAtMs,
content: sanitizedContent,
contentType,
text: rawMessage.text ?? null,
participantIds: [...participantIds],
readStatus,
messageStatus,
unreadCount,
return {
ok: true,
message: {
...rawMessage,
content: { ...rawMessage.content },
participantIds: [...rawMessage.participantIds],
},
};
return { ok: true, message };
};
const syncAnomalyFor = (
+7 -4
View File
@@ -4,6 +4,7 @@ import type { WebSocket } from "@fastify/websocket";
import {
ONETALK_CLIENT_FRAME_TYPES,
ONETALK_ERROR_CODES,
ONETALK_PROTOCOL_VERSION,
createOneTalkAcceptedFrame,
createOneTalkAnchorSnapshotFrame,
createOneTalkContactProfileAckFrame,
@@ -27,6 +28,7 @@ import {
type OneTalkScope,
type OneTalkSyncStatusPayload,
} from "@trade-message-center/onetalk-contract";
import { toOneTalkCenterMessage } from "../onetalk/read-projection.ts";
import {
OneTalkDatabaseError,
@@ -228,7 +230,8 @@ const policyIsCurrent = (
): boolean => {
return (
(options.cutoverPolicy?.isCurrent(epoch) ?? true) &&
(options.cutoverPolicy?.canAdmit("bright-v2", connectionType, 2) ?? true)
(options.cutoverPolicy?.canAdmit("bright-v3", connectionType, ONETALK_PROTOCOL_VERSION) ??
true)
);
};
@@ -596,7 +599,7 @@ const handleAuthenticatedFrame = async (
if (result.status === "accepted") {
pending.commitGuard.assertValid();
await options.registry.publishMessageCreated({
message: result.message,
message: toOneTalkCenterMessage(result.message),
requestId: pending.frame.requestId,
scope: pending.mind.mindScope,
policyEpoch: pending.policyEpoch,
@@ -902,7 +905,7 @@ const handleAuthenticatedFrame = async (
}
if (result.status === "accepted") {
await options.registry.publishMessageCreated({
message: result.message,
message: toOneTalkCenterMessage(result.message),
requestId: frame.requestId,
scope: contextForMindScope(context),
policyEpoch,
@@ -966,7 +969,7 @@ const handleMessage = async (
const frame = parsed.decoded.frame;
if (
options.cutoverPolicy &&
options.cutoverPolicy.canAdmit("bright-v2", frame.connectionType, frame.protocolVersion) !==
options.cutoverPolicy.canAdmit("bright-v3", frame.connectionType, frame.protocolVersion) !==
true
) {
closeSocket(socket, CLOSE_TRY_AGAIN_LATER, "authorization_unavailable");
+2 -1
View File
@@ -5,6 +5,7 @@ import type { FastifyInstance, FastifyPluginCallback, FastifyRequest } from "fas
import type { WebSocket } from "@fastify/websocket";
import {
createUnavailableAuthorizationReader,
ONETALK_PROTOCOL_VERSION,
type OneTalkAuthorizationReader,
} from "@trade-message-center/onetalk-contract";
@@ -62,7 +63,7 @@ const registerWebsocketRoutes = (
if (
routeType !== null &&
cutoverPolicy !== undefined &&
cutoverPolicy.canAdmit("bright-v2", routeType, 2) !== true
cutoverPolicy.canAdmit("bright-v3", routeType, ONETALK_PROTOCOL_VERSION) !== true
) {
reportDiagnostic(onDiagnostic, {
event: "ws_decision",
+7 -4
View File
@@ -3,12 +3,14 @@
import type { WebSocket } from "@fastify/websocket";
import {
ONETALK_ERROR_CODES,
ONETALK_PROTOCOL_VERSION,
createOneTalkErrorFrame,
createOneTalkMessageCreatedFrame,
createOneTalkPluginStatusFrame,
createOneTalkSendCommandFrame,
createOneTalkSyncStatusFrame,
type OneTalkAuthorizationReader,
type OneTalkCenterMessage,
type OneTalkErrorCode,
type OneTalkFrame,
type OneTalkMessage,
@@ -116,7 +118,7 @@ export type OneTalkConnectionRegistry = {
register: (connection: OneTalkRegisteredConnection) => () => void;
unregister: (socket: WebSocket) => void;
publishMessageCreated: (input: {
message: OneTalkMessage;
message: OneTalkCenterMessage;
requestId: string;
scope: OneTalkMindScope;
policyEpoch?: number;
@@ -222,7 +224,8 @@ export const createOneTalkConnectionRegistry = (options: {
let suppressStatusNotifications = false;
const policyAdmits = (connectionType: "plugin" | "mind_page"): boolean =>
options.cutoverPolicy?.canAdmit("bright-v2", connectionType, 2) ?? true;
options.cutoverPolicy?.canAdmit("bright-v3", connectionType, ONETALK_PROTOCOL_VERSION) ??
true;
const epochIsCurrent = (epoch: number): boolean =>
options.cutoverPolicy?.isCurrent(epoch) ?? true;
const currentEpoch = (): number => options.cutoverPolicy?.capture() ?? 0;
@@ -290,7 +293,7 @@ export const createOneTalkConnectionRegistry = (options: {
candidates: OneTalkRegisteredConnection[];
requestId: string;
eventType: "plugin.status" | "sync.status" | "message.created";
message?: OneTalkMessage;
message?: OneTalkCenterMessage;
policyEpoch?: number;
buildFrame: (connection: OneTalkRegisteredConnection) => OneTalkFrame;
}): Promise<void> => {
@@ -487,7 +490,7 @@ export const createOneTalkConnectionRegistry = (options: {
};
const publishMessageCreated = async (input: {
message: OneTalkMessage;
message: OneTalkCenterMessage;
requestId: string;
scope: OneTalkMindScope;
policyEpoch?: number;
+1 -2
View File
@@ -436,8 +436,7 @@ test("does not apply an old history response after an account generation changes
direction: "received",
sentAtMs: 100,
readStatus: "read",
contentType: "text",
content: { text: "stale" },
content: { version: 1, kind: "text", text: "stale" },
},
],
page: { hasMore: false, nextCursor: null },
+5 -5
View File
@@ -356,15 +356,15 @@ test("enforces exact Mind status-code pairs and transport failure boundaries", a
);
});
test("cutover pause is fail-closed and Bright v2 can resume", () => {
test("cutover pause is fail-closed and Bright v3 can resume", () => {
const policy = createOneTalkCutoverPolicy();
assert.equal(policy.canAdmit("bright-v2", "plugin", 2), true);
assert.equal(policy.canAdmit("bright-v3", "plugin", 3), true);
policy.pause();
assert.equal(policy.canAdmit("bright-v2", "plugin", 2), false);
assert.equal(policy.canAdmit("bright-v3", "plugin", 3), false);
assert.equal(policy.resume(), true);
policy.pause();
assert.equal(policy.resume(), true);
assert.equal(policy.snapshot().paused, false);
assert.equal(policy.canAdmit("bright-v2", "plugin", 2), true);
assert.equal(policy.canAdmit("legacy", "plugin", 2), false);
assert.equal(policy.canAdmit("bright-v3", "plugin", 3), true);
assert.equal(policy.canAdmit("legacy", "plugin", 3), false);
});
+11 -52
View File
@@ -30,9 +30,7 @@ const message = (overrides: Partial<OneTalkObservedMessage> = {}): OneTalkObserv
senderId: "sender-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
content: { text: { content: "hello" } },
contentType: 101,
text: "hello",
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "login-user-1"],
readStatus: 1,
messageStatus: 2,
@@ -289,20 +287,17 @@ test("requires a discovered conversation and keeps message facts idempotent", as
assert.equal(harness.conversations.get("account-1:conversation-1")?.messageCount, 1);
});
test("records malformed observations as merged, sanitized anomalies", async () => {
test("records malformed observations as merged metadata-only anomalies", async () => {
const harness = createRepositoryHarness();
const service = createOneTalkService(harness.repository);
await service.discoverConversation(context, "conversation-1", undefined, "direct");
const malformed = message({
const malformed = {
...message(),
messageId: undefined,
participantIds: undefined,
content: {
text: { content: "diagnostic text" },
token: "session-secret",
nested: { cookie: "cookie-secret" },
},
});
content: { version: 1, kind: "text", text: "diagnostic text", token: "session-secret" },
} as unknown as OneTalkObservedMessage;
const first = await service.observeMessage(context, "history", malformed);
const second = await service.observeMessage(context, "history", malformed);
@@ -312,40 +307,11 @@ test("records malformed observations as merged, sanitized anomalies", async () =
assert.equal(harness.anomalies.size, 1);
const anomaly = [...harness.anomalies.values()][0];
assert.equal(anomaly.count, 2);
assert.deepEqual(anomaly.input.missingFields.sort(), ["messageId", "participantIds"]);
assert.deepEqual(anomaly.input.payload, {
fields: {
content: { present: true, type: "object" },
conversationId: { present: true, type: "string" },
contentType: { present: true, type: "number" },
direction: { present: true, type: "string" },
messageId: { present: false, type: "undefined" },
messageStatus: { present: true, type: "number" },
participantIds: { present: false, type: "undefined" },
readStatus: { present: true, type: "number" },
senderId: { present: true, type: "string" },
sentAtMs: { present: true, type: "number" },
text: { present: true, type: "string" },
unreadCount: { present: true, type: "number" },
},
keys: [
"content",
"contentType",
"conversationId",
"direction",
"messageId",
"messageStatus",
"participantIds",
"readStatus",
"senderId",
"sentAtMs",
"text",
"unreadCount",
],
});
assert.deepEqual(anomaly.input.missingFields, ["content"]);
assert.deepEqual(anomaly.input.payload, { fields: ["content"] });
});
test("sanitizes sensitive content before inserting a valid message", async () => {
test("persists a valid normalized content object without server-side reinterpretation", async () => {
const harness = createRepositoryHarness();
const service = createOneTalkService(harness.repository);
await service.discoverConversation(context, "conversation-1", undefined, "direct");
@@ -354,20 +320,13 @@ test("sanitizes sensitive content before inserting a valid message", async () =>
context,
"send_confirmation",
message({
content: {
text: { content: "hello" },
token: "session-secret",
nested: { cookie: "cookie-secret", value: true },
},
content: { version: 1, kind: "text", text: "hello" },
}),
);
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(result.message.content, {
text: { content: "hello" },
nested: { value: true },
});
assert.deepEqual(result.message.content, { version: 1, kind: "text", text: "hello" });
});
test("advances only valid shared anchors and preserves incomplete outcomes", async () => {
+11 -5
View File
@@ -75,8 +75,7 @@ const message = (messageId: string, sentAtMs: number): CenterMessage => ({
direction: "received",
sentAtMs,
readStatus: "read",
contentType: "text",
content: { text: messageId },
content: { version: 1, kind: "text", text: messageId },
});
const createDatabaseStub = (): DatabaseConnection => ({
@@ -291,7 +290,7 @@ test("forwards the half-open window, summary purpose, and opaque history cursor
});
const body = response.json();
assert.deepEqual(body.page, { hasMore: true, nextCursor: "next-history-cursor" });
assert.equal(body.messages[0].contentType, "text");
assert.equal(body.messages[0].content.kind, "text");
for (const excluded of [
"messageStatus",
"unreadCount",
@@ -541,7 +540,7 @@ test("maps database errors without disclosing database details", async () => {
}
});
test("fences an in-flight list when Bright v2 pauses during the read await", async () => {
test("fences an in-flight list when Bright v3 pauses during the read await", async () => {
let begin!: () => void;
let release!: (value: Awaited<ReturnType<OneTalkReadService["listConversations"]>>) => void;
const began = new Promise<void>((resolve) => {
@@ -582,7 +581,7 @@ test("fences an in-flight list when Bright v2 pauses during the read await", asy
}
});
test("serves a guarded harness that keeps HTTP cursors opaque and escapes rendered text", async () => {
test("serves a guarded harness that renders normalized media without raw decoding", async () => {
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([authorizationRecord]),
@@ -613,9 +612,16 @@ test("serves a guarded harness that keeps HTTP cursors opaque and escapes render
/fields\.account\.addEventListener\('input', resetForAccountOrQueryChange\)/,
);
assert.match(response.body, /escapeHtml/);
assert.match(response.body, /isNormalizedContent/);
assert.match(response.body, /JSON\.stringify\(message\.content, null, 2\)/);
assert.match(response.body, /message-image/);
assert.match(response.body, /图片预览加载失败/);
assert.match(response.body, /downloadState/);
assert.match(response.body, /message\.created/);
assert.match(response.body, /credentials: 'include'/);
assert.equal(response.body.includes("decodeOneTalk"), false);
assert.equal(response.body.includes("contentType"), false);
assert.equal(response.body.includes("custom"), false);
assert.equal(response.body.includes("deviceId"), false);
assert.equal(response.body.includes("scope: state.scope"), false);
assert.equal(response.body.includes("x-onetalk-mind-user-id"), false);
@@ -32,9 +32,7 @@ const integrationMessage = (
senderId: "sender-1",
direction: "received",
sentAtMs,
content: { text: { content: "integration" } },
contentType: 101,
text: "integration",
content: { version: 1, kind: "text", text: "integration" },
participantIds: ["sender-1", "login-user-1"],
readStatus: 1,
messageStatus: 2,
@@ -119,7 +117,7 @@ test(
);
const anomaly = await service.observeMessage(context, "live", {
messageId: "integration-invalid-message",
});
} as unknown as OneTalkObservedMessage);
assert.equal(first.status, "accepted");
assert.equal(duplicate.status, "duplicate");
+116 -325
View File
@@ -3,6 +3,8 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { OneTalkMessage } from "@trade-message-center/onetalk-contract";
import {
createOneTalkReadService,
decodeOneTalkHistoryReadCursor,
@@ -13,12 +15,12 @@ import {
type CenterMessage,
type OneTalkReadConversationQuery,
type OneTalkReadConversationRow,
type OneTalkReadDiagnostic,
type OneTalkReadHistoryQuery,
type OneTalkReadListQuery,
type OneTalkReadMessageRow,
type OneTalkReadRepository,
} from "../src/onetalk/index.ts";
import { projectCenterMessage, toOneTalkCenterMessage } from "../src/onetalk/read-projection.ts";
const scope = {
mindUserId: "mind-user-1",
@@ -49,8 +51,7 @@ const message = (overrides: Partial<OneTalkReadMessageRow> = {}): OneTalkReadMes
senderId: "sender-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
contentType: 1,
content: { text: { content: "hello" } },
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "account-1"],
readStatus: 1,
...overrides,
@@ -66,38 +67,6 @@ const messageFieldsForProjectionTest = () => ({
readStatus: "read" as const,
});
const encodedMetadata = (value: Record<string, unknown>): string => {
return Buffer.from(JSON.stringify(value), "utf8").toString("base64");
};
const imageMetadata = (overrides: Record<string, unknown> = {}): string => {
return encodedMetadata({
fileId: "image-file-1",
url: "https://cdn.example.com/image.jpg",
width: 1280,
height: 720,
size: 42_000,
suffix: "jpg",
md5: "a".repeat(32),
isOriginal: true,
...overrides,
});
};
const attachmentMetadata = (overrides: Record<string, unknown> = {}): string => {
return encodedMetadata({
params: { name: "quote.pdf" },
size: 24_000,
type: "application/pdf",
extensionType: "pdf",
url: "https://cdn.example.com/quote.pdf",
thumbnailUrl: "https://cdn.example.com/quote-thumbnail.jpg",
downloadUrl: "",
md5: "b".repeat(32),
...overrides,
});
};
const createRepositoryHarness = (
options: {
listRows?: () => OneTalkReadConversationRow[];
@@ -296,8 +265,8 @@ test("round-trips a valid list cursor with a long query", () => {
test("uses one snapshot and half-open window for history page pairs", async () => {
const rows = [
message({ messageId: "message-b", sentAtMs: 20 }),
message({ messageId: "message-a", sentAtMs: 10 }),
message({ messageId: "message-b", sentAtMs: 20 }),
];
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
@@ -318,7 +287,7 @@ test("uses one snapshot and half-open window for history page pairs", async () =
if (firstPage.status !== "accepted") return;
assert.deepEqual(
firstPage.messages.map(({ messageId }) => messageId),
["message-b"],
["message-a"],
);
assert.equal(firstPage.page.hasMore, true);
assert.ok(firstPage.page.nextCursor);
@@ -346,39 +315,6 @@ test("uses one snapshot and half-open window for history page pairs", async () =
assert.equal(harness.historyQueries.length, 1);
});
test("returns the newest history page while keeping each page chronological", async () => {
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => [
message({ messageId: "message-c", sentAtMs: 30 }),
message({ messageId: "message-b", sentAtMs: 20 }),
message({ messageId: "message-a", sentAtMs: 10 }),
],
});
const service = createOneTalkReadService(harness.repository, {
now: () => new Date("2026-09-03T00:00:00.000Z"),
});
const result = await service.readHistory({
scope,
conversationId: "conversation-1",
limit: 2,
});
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(
result.messages.map(({ messageId }) => messageId),
["message-b", "message-c"],
);
assert.ok(result.page.nextCursor);
const cursor = decodeOneTalkHistoryReadCursor(result.page.nextCursor);
assert.deepEqual(cursor && { sentAtMs: cursor.sentAtMs, messageId: cursor.messageId }, {
sentAtMs: 20,
messageId: "message-b",
});
});
test("keeps partial ordinary history readable but gates summary reads", async () => {
const harness = createRepositoryHarness({ messageRows: () => [message()] });
const service = createOneTalkReadService(harness.repository);
@@ -395,283 +331,152 @@ test("keeps partial ordinary history readable but gates summary reads", async ()
assert.equal(harness.historyQueries.length, 1);
});
test("projects only verified content forms and omits raw message fields", async () => {
const diagnostics: OneTalkReadDiagnostic[] = [];
test("projects normalized text, image, and file content without raw reinterpretation", async () => {
const image = {
version: 1,
kind: "image",
fileId: "image-file-1",
extension: "jpg",
sizeBytes: 42_000,
width: 1280,
height: 720,
isOriginal: true,
md5: "a".repeat(32),
previewUrl:
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=oneTalk&fileAction=imagePreview&id=image-file-1&scene=oneTalk",
urlScope: "onetalk_session",
} as const;
const file = {
version: 1,
kind: "file",
fileId: "file-1",
parentId: "parent-1",
fileName: "quote.pdf",
extension: "pdf",
sizeBytes: 24_000,
md5: "b".repeat(32),
previewUrl: null,
thumbnailUrl: null,
downloadUrl:
"https://clouddisk.alibaba.com/file/redirectFileUrl.htm?appkey=oneTalk&fileAction=download&id=file-1&parentId=parent-1&scene=oneTalk",
downloadState: "available",
urlScope: "onetalk_session",
} as const;
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => [
message({
messageId: "business",
sentAtMs: 40,
content: {
business: { subject: "must not leave Bright" },
text: { content: "must remain unknown" },
},
contentType: 9,
readStatus: 4,
}),
message({
messageId: "attachment",
sentAtMs: 30,
contentType: 101,
content: { custom: { type: 10010, data: attachmentMetadata() } },
}),
message({
messageId: "image",
sentAtMs: 20,
contentType: 101,
content: { custom: { type: 7, data: imageMetadata() } },
}),
message({ messageId: "file", sentAtMs: 30, content: file, readStatus: 0 }),
message({ messageId: "image", sentAtMs: 20, content: image }),
message({
messageId: "text",
sentAtMs: 10,
content: { text: { content: "hello" } },
content: { version: 1, kind: "text", text: "hello" },
}),
],
});
const service = createOneTalkReadService(harness.repository, {
onDiagnostic: (diagnostic) => diagnostics.push(diagnostic),
});
const service = createOneTalkReadService(harness.repository);
const result = await service.readHistory({ scope, conversationId: "conversation-1", limit: 3 });
const result = await service.readHistory({ scope, conversationId: "conversation-1", limit: 4 });
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(
result.messages.map(({ contentType, content }) => ({ contentType, content })),
[
{ contentType: "text", content: { text: "hello" } },
{ contentType: "img", content: { url: "https://cdn.example.com/image.jpg" } },
{
contentType: "attachment",
content: { name: "quote.pdf", url: "https://cdn.example.com/quote.pdf" },
},
{ contentType: "unknown", content: null },
],
result.messages.map(({ content }) => content),
[{ version: 1, kind: "text", text: "hello" }, image, file],
);
const unknown = result.messages[3];
assert.equal(unknown?.readStatus, "unread");
assert.deepEqual(diagnostics, [
{
code: "unknown_read_status",
},
]);
assert.deepEqual(Object.keys(diagnostics[0] ?? {}), ["code"]);
assert.equal(result.messages[2]?.readStatus, "unread");
for (const projected of result.messages) {
assert.deepEqual(Object.keys(projected).sort(), [
"content",
"conversationId",
"direction",
"messageId",
"participantIds",
"readStatus",
"senderId",
"sentAtMs",
]);
assert.equal("contentType" in projected, false);
assert.equal("messageStatus" in projected, false);
assert.equal("unreadCount" in projected, false);
assert.equal("text" in projected, false);
assert.equal("subject" in projected, false);
assert.equal("contentTypeRaw" in projected, false);
}
});
test("rejects type-only and malformed image metadata", async () => {
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => [
message({ messageId: "type-only", contentType: 101, content: { custom: { type: 7 } } }),
message({
messageId: "invalid-base64",
contentType: 101,
content: { custom: { type: 7, data: "not base64" } },
}),
message({
messageId: "invalid-utf8",
contentType: 101,
content: {
custom: { type: 7, data: Buffer.from([0xc3, 0x28]).toString("base64") },
},
}),
message({
messageId: "invalid-json",
contentType: 101,
content: { custom: { type: 7, data: Buffer.from("{", "utf8").toString("base64") } },
}),
message({
messageId: "incomplete-metadata",
contentType: 101,
content: {
custom: { type: 7, data: imageMetadata({ url: "javascript:alert(1)" }) },
},
}),
],
});
const service = createOneTalkReadService(harness.repository);
const result = await service.readHistory({ scope, conversationId: "conversation-1", limit: 5 });
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(
result.messages.map(({ contentType, content }) => ({ contentType, content })),
[
{ contentType: "unknown", content: null },
{ contentType: "unknown", content: null },
{ contentType: "unknown", content: null },
{ contentType: "unknown", content: null },
{ contentType: "unknown", content: null },
],
);
});
test("rejects malformed required attachment metadata and skips unusable optional URLs", async () => {
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => [
message({
messageId: "valid-empty-download-url",
contentType: 101,
content: { custom: { type: 10010, data: attachmentMetadata() } },
}),
message({
messageId: "type-only",
contentType: 101,
content: { custom: { type: 10010 } },
}),
message({
messageId: "missing-name",
contentType: 101,
content: {
custom: { type: 10010, data: attachmentMetadata({ params: { name: "" } }) },
},
}),
message({
messageId: "invalid-download-url",
contentType: 101,
content: {
custom: {
type: 10010,
data: attachmentMetadata({ downloadUrl: "javascript:alert(1)" }),
},
},
}),
],
});
const service = createOneTalkReadService(harness.repository);
const result = await service.readHistory({ scope, conversationId: "conversation-1", limit: 4 });
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(
result.messages.map(({ contentType, content }) => ({ contentType, content })),
[
{
contentType: "attachment",
content: { name: "quote.pdf", url: "https://cdn.example.com/quote.pdf" },
},
{ contentType: "unknown", content: null },
{ contentType: "unknown", content: null },
{
contentType: "attachment",
content: { name: "quote.pdf", url: "https://cdn.example.com/quote.pdf" },
},
],
);
});
test("falls back through omitted attachment download and thumbnail URLs", async () => {
const withoutDownloadUrl = attachmentMetadata({ downloadUrl: undefined });
const withoutDownloadOrUrl = attachmentMetadata({ downloadUrl: undefined, url: undefined });
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => [
message({
messageId: "fallback-thumbnail",
sentAtMs: 20,
contentType: 101,
content: { custom: { type: 10010, data: withoutDownloadOrUrl } },
}),
message({
messageId: "fallback-url",
sentAtMs: 10,
contentType: 101,
content: { custom: { type: 10010, data: withoutDownloadUrl } },
}),
],
});
const service = createOneTalkReadService(harness.repository);
const result = await service.readHistory({ scope, conversationId: "conversation-1", limit: 2 });
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(
result.messages.map(({ contentType, content }) => ({ contentType, content })),
[
{
contentType: "attachment",
content: { name: "quote.pdf", url: "https://cdn.example.com/quote.pdf" },
},
{
contentType: "attachment",
content: { name: "quote.pdf", url: "https://cdn.example.com/quote-thumbnail.jpg" },
},
],
);
});
test("does not treat malformed custom metadata as verified text", async () => {
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => [
message({
contentType: 1,
content: { text: { content: "must remain unknown" }, custom: { type: "invalid" } },
}),
],
});
const service = createOneTalkReadService(harness.repository);
const result = await service.readHistory({ scope, conversationId: "conversation-1", limit: 1 });
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.deepEqual(result.messages[0], {
messageId: "message-1",
test("projects one persisted media fact identically for history and message.created", () => {
const fact: OneTalkMessage = {
messageId: "image-1",
conversationId: "conversation-1",
senderId: "sender-1",
participantIds: ["sender-1", "account-1"],
direction: "received",
sentAtMs: 1_700_000_000_000,
readStatus: "read",
contentType: "unknown",
content: null,
});
});
test("diagnostic sink failures do not change a successful read", async () => {
const harness = createRepositoryHarness({
conversation: () => conversation({ historyComplete: true }),
messageRows: () => [message({ readStatus: 2 })],
});
const service = createOneTalkReadService(harness.repository, {
onDiagnostic: () => {
throw new Error("diagnostic sink failure");
readStatus: 1,
messageStatus: 2,
unreadCount: 0,
content: {
version: 1,
kind: "image",
fileId: "image-file-1",
extension: "jpg",
sizeBytes: 42_000,
width: 1280,
height: 720,
isOriginal: true,
md5: null,
previewUrl: null,
urlScope: "onetalk_session",
},
};
const historyMessage = projectCenterMessage({
channelAccountId: scope.channelAccountId,
...fact,
});
const liveMessage = toOneTalkCenterMessage(fact);
const result = await service.readHistory({ scope, conversationId: "conversation-1" });
assert.equal(result.status, "accepted");
if (result.status !== "accepted") return;
assert.equal(result.messages[0]?.contentType, "text");
assert.equal(result.messages[0]?.readStatus, "unread");
assert.deepEqual(historyMessage, liveMessage);
assert.equal("contentType" in historyMessage, false);
assert.equal("text" in historyMessage, false);
});
test("CenterMessage accepts verified image and attachment values", () => {
test("CenterMessage accepts normalized image and file values", () => {
const verifiedImage = {
...messageFieldsForProjectionTest(),
contentType: "img",
content: { url: "https://cdn.example.com/image.jpg" },
} satisfies CenterMessage;
const verifiedAttachment = {
...messageFieldsForProjectionTest(),
contentType: "attachment",
content: {
name: "quote.pdf",
url: "https://cdn.example.com/quote.pdf",
version: 1,
kind: "image",
fileId: "image-file-1",
extension: "jpg",
sizeBytes: 42_000,
width: 1280,
height: 720,
isOriginal: true,
md5: null,
previewUrl: null,
urlScope: "onetalk_session",
},
} satisfies CenterMessage;
const verifiedFile = {
...messageFieldsForProjectionTest(),
content: {
version: 1,
kind: "file",
fileId: "file-1",
parentId: "parent-1",
fileName: "quote.pdf",
extension: "pdf",
sizeBytes: 24_000,
md5: null,
previewUrl: null,
thumbnailUrl: null,
downloadUrl: null,
downloadState: "not_provided",
urlScope: "onetalk_session",
},
} satisfies CenterMessage;
assert.equal(verifiedImage.content.url, "https://cdn.example.com/image.jpg");
assert.equal(verifiedAttachment.content.name, "quote.pdf");
assert.equal(verifiedImage.content.kind, "image");
assert.equal(verifiedFile.content.fileName, "quote.pdf");
});
test("keeps history cursor codecs separate from legacy and list cursor shapes", () => {
@@ -686,18 +491,4 @@ test("keeps history cursor codecs separate from legacy and list cursor shapes",
});
assert.equal(decodeOneTalkListCursor(historyCursor), null);
assert.equal(decodeOneTalkHistoryReadCursor("eyJ2IjoxLCJhIjoiYSJ9"), null);
const legacyHistoryCursor = Buffer.from(
JSON.stringify({
a: "account-1",
c: "conversation-1",
f: null,
m: "message-1",
o: 10,
t: 20,
v: 1,
x: 11,
}),
"utf8",
).toString("base64url");
assert.equal(decodeOneTalkHistoryReadCursor(legacyHistoryCursor), null);
});
@@ -125,8 +125,6 @@ test(
direction,
observation_type,
sent_at_ms,
content_type,
text,
content,
participant_ids,
read_status,
@@ -146,9 +144,7 @@ test(
'received',
'history',
${sentAtMs},
1,
'visible text',
${client.json({ text: { content: "visible text" } })},
${client.json({ version: 1, kind: "text", text: "visible text" })},
${["sender-1", channelAccountId]},
1,
2,
+21 -9
View File
@@ -9,6 +9,7 @@ import {
ONETALK_PROTOCOL_VERSION,
createMockAuthorizationReader,
type MockAuthorizationRecord,
type OneTalkCenterMessage,
type OneTalkMindScope,
type OneTalkMessage,
type OneTalkObservedMessage,
@@ -74,9 +75,7 @@ const message = (messageId = "message-1"): OneTalkMessage => {
senderId: "sender-1",
direction: "received",
sentAtMs: 1_700_000_000_000,
content: { text: { content: "hello" } },
contentType: 101,
text: "hello",
content: { version: 1, kind: "text", text: "hello" },
participantIds: ["sender-1", "login-user-1"],
readStatus: 1,
messageStatus: 2,
@@ -84,6 +83,17 @@ const message = (messageId = "message-1"): OneTalkMessage => {
};
};
const centerMessage = (value: OneTalkMessage): OneTalkCenterMessage => ({
messageId: value.messageId,
conversationId: value.conversationId,
senderId: value.senderId,
participantIds: [...value.participantIds],
direction: value.direction,
sentAtMs: value.sentAtMs,
readStatus: value.readStatus === 1 ? "read" : "unread",
content: { ...value.content },
});
const createService = (observeMessage: OneTalkService["observeMessage"]): OneTalkService => {
return {
discoverConversation: async (_context, conversationId, _guard, conversationKind) => ({
@@ -281,7 +291,9 @@ test("ACKs each observation and publishes only a newly inserted fact", async ()
conversationId: "conversation-1",
messageId: "message-1",
});
assert.deepEqual((await firstCreated).payload, { message: message("message-1") });
assert.deepEqual((await firstCreated).payload, {
message: centerMessage(message("message-1")),
});
const duplicateAck = nextMessage(plugin);
plugin.send(JSON.stringify(observedFrame("observe-2")));
@@ -660,7 +672,7 @@ test("commits and publishes a confirmed sent message before send.result", async
frames.map((frame) => frame.type),
["message.created", "send.result"],
);
assert.deepEqual(frames[0].payload, { message: sentMessage });
assert.deepEqual(frames[0].payload, { message: centerMessage(sentMessage) });
assert.deepEqual(frames[1].payload, {
status: "confirmed_sent",
message: sentMessage,
@@ -1019,7 +1031,7 @@ test("registry publishes only to the exact Mind scope and reports send failures"
registry.register(connection(otherSocket, otherScope, "binding-2"));
await registry.publishMessageCreated({
message: message(),
message: centerMessage(message()),
requestId: "observe-1",
scope: mindScope,
});
@@ -1030,7 +1042,7 @@ test("registry publishes only to the exact Mind scope and reports send failures"
authorization.revoke(pluginScope);
await registry.publishMessageCreated({
message: message("message-2"),
message: centerMessage(message("message-2")),
requestId: "observe-2",
scope: mindScope,
});
@@ -1063,7 +1075,7 @@ test("does not publish live facts to a Mind connection that did not request read
});
await registry.publishMessageCreated({
message: message(),
message: centerMessage(message()),
requestId: "observe-unauthorized-page",
scope: mindScope,
});
@@ -1306,7 +1318,7 @@ test("claims confirmation once and makes a terminal late confirmation a no-op",
assert.equal(processCalls, 1);
});
test("pausing Bright v2 closes existing sockets with 1013 without an error frame", async () => {
test("pausing Bright v3 closes existing sockets with 1013 without an error frame", async () => {
const policy = createOneTalkCutoverPolicy();
const app = createApp(testConfig, {
database: createDatabaseStub(),