feat: update message synchronization flow

This commit is contained in:
YBF
2026-09-15 02:37:04 +08:00
parent 90fb2c065f
commit 6be3b8393d
54 changed files with 4211 additions and 73 deletions
-37
View File
@@ -1,37 +0,0 @@
## 概览
本 PR 汇总近期 OneTalk 消息中心的同步、单会话历史重建、商品消息采集和页面交互整理,并补齐共享协议、服务端、Chrome 扩展、数据库迁移及回归测试。
## 最近改动
- **消息同步链路**
- 移除旧的 flat-history publication flow。
- 调整 Plugin ↔ Bright 的同步、ACK、checkpoint 和生命周期处理,确保消息与完成状态按会话边界推进。
- **单会话历史重建**
- 新增 `POST /api/bright/onetalk/accounts/:channelAccountId/conversations/:conversationId/history/rebuild`
- 使用独立的 `rebuild` 权限、精确会话范围和新鲜 heartbeat 选择唯一 Plugin。
- 新增 `storage.delete.*``history.sync.*``rebuild.status` 协议帧,并升级 OneTalk protocol version 到 v7。
-`channelAccountId + conversationId` 清理 Plugin 本地消息账本,再通过 Bright 事务清理目标消息/会话级异常并重置消息派生状态。
- 通过 `historyGeneration` 隔离重建前后的旧消息、ACK、页面结果和完成结果;服务端 reset 提交后复用现有单会话 full sync,并区分 `server_reset_committed` 与后续 resync 结果。
- **商品消息采集**
- 严格识别 `https://chinese.alibaba.com/product-detail/...-<productId>.html` 商品详情链接。
- 归一化为不带 query/token 的 `{ version, kind: "product", sourceUrl, productId }`,沿用现有观察、持久化和读取链路。
- 增加 `product` 数据库约束和迁移,不回填既有消息。
- **OneTalk 页面 DOM 交互整理**
- 将动作状态提示、会话 ID 复制、文件上传器定位和会话选择辅助逻辑收敛到 `main-page/dom/`
- 引入 `@testing-library/dom``@testing-library/user-event``jsdom` 验证真实 DOM 控件交互,同时保留现有 SDK 发送路径和 bridge 行为。
- **契约、数据层与测试**
- 扩展共享消息内容、同步、授权、读取和 WebSocket wire contract,并保持严格字段校验。
- 新增 `0012_onetalk_history_generation.sql``0013_nifty_thunderbird.sql` 迁移。
- 补充或更新 contract、Chrome extension、server 的同步、历史重建、商品内容、数据库读取和 DOM 回归测试。
## 影响范围
- `packages/onetalk-contract`
- `apps/chrome-extension`
- `apps/server`
- OneTalk 数据库迁移及相关测试
@@ -0,0 +1,7 @@
{"file":".trellis/spec/project/architecture.md","reason":"Review ownership, dependency direction, and cross-layer final-action boundaries."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Review duplicate requests, late acknowledgements, connection replacement, and final action ordering."}
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Review fingerprint collision and same-key/different-content conflict behavior."}
{"file":".trellis/spec/project/database-query-composition.md","reason":"Review scoped base/enrichment reads and ensure no SQL or Drizzle JOIN was introduced."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Review bridge payload allowlists, MAIN-world boundary, existing sync compatibility, and protocol event ownership."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Review IndexedDB migration preservation, durable-first upload, recovery, and ACK terminal transitions."}
{"file":".trellis/spec/server/backend/database-guidelines.md","reason":"Review schema comments, migration execution, server transaction, idempotency, and publication behavior."}
@@ -0,0 +1,171 @@
# React 卡片监测设计
## 1. Ownership and flow
```text
OneTalk DOM mutation
-> MAIN card observer (mount trigger only)
-> MAIN React Fiber card reader + content normalizer
-> page bridge exact envelope
-> ISOLATED stateless forwarder
-> Service Worker rendered-card coordinator
-> IndexedDB rendered-card ledger (durable first)
-> Bright rendered-card.observed
-> PostgreSQL rendered-card fact transaction
-> rendered-card.ack
-> local terminal ledger update
-> authorized Mind message.updated
```
The existing message path remains independent:
```text
base message durable candidate -> messages.observed -> onetalk_message.content
-> message.ack -> message.created
```
The card coordinator sends only after the matching base candidate is confirmed. A card observation therefore cannot create a message fact, advance a history anchor, or alter initial message publication.
The rendered-card path is a content supplement, not message ingestion. Its only cross-layer identity is the already-existing `[channelAccountId, conversationId, messageId]`; its own `observedAtMs` records when the supplement was rendered, never when the message was sent.
## 2. MAIN-world card reader
### 2.1 Trigger and lifecycle
- A dedicated `main-page/card-observer/` owns one MutationObserver scoped to the message-list root. It watches `childList + subtree`, collects newly added `.message-item-wrapper` elements, and schedules one bounded post-commit read with `setTimeout(0)`.
- The observer has an element `WeakSet` for one DOM incarnation. It disconnects on `pagehide`; if the message-list root is replaced, it disconnects and rebinds. It does not watch attributes or character data and it does not retain removed elements.
- The timer is only a React-commit boundary, not a retry loop. If Fiber data is absent after that read, the node is rejected with a minimal diagnostic. A subsequent actual mount mutation may produce one new attempt.
### 2.2 Fiber adapter
`react-card-reader.ts` is the only module permitted to inspect `__reactFiber$*` and Fiber `memoizedProps`. It:
1. locates the Fiber property dynamically on the mounted wrapper/descendants;
2. climbs a bounded parent chain to one props record containing `itemData`;
3. uses `itemData` only for the named identity/classification allowlist;
4. searches the card's bounded Fiber subtree for the single type-specific template `props.data` record that owns its visible business fields;
5. returns either typed `OneTalkRenderedCardContent` or a diagnostic code, never raw `itemData`, template context or Fiber objects.
Required common fields are:
| Fiber source | Rule | Use |
| --- | --- | --- |
| `itemData.messageId` | safe integer/string normalized by existing message-ID normalizer | message identity |
| `itemData.conversationCode` | nonblank string and equal to selected `data-cid` before and after extraction | conversation identity |
| `itemData.messageType` | `rec` or `send` | cross-check direction against base candidate |
| `itemData.sendTime` | non-negative safe integer | cross-check metadata; not a replacement identity |
| `itemData.msgType` | safe integer | classifier guard only |
`channelAccountId` is still read by the existing MAIN page-context owner. The reader returns only a normalized `RenderedCardObservation`; the DOM node and Fiber are not bridged or persisted.
The template-data search is intentionally not a generic object crawl. Each card reader owns a verified predicate over its direct component props, then projects exact allowlisted paths. For example, the live order sample's template data is recognized by the concurrent presence of `cardTitle`, `productInfoList`, `orderStatusText` and `shippingAddress`; only the projection below leaves MAIN world.
### 2.3 Rendered-card content contract
Base `OneTalkMessageContent` remains immutable. This feature introduces a separate, exact `OneTalkRenderedCardContent` union for the visual information the existing message/history source cannot provide. Its values are all produced from typed template projections in MAIN world.
| Kind | Required Fiber evidence | Exact displayed projection | Consumer-visible purpose | Rejected / never bridged |
| --- | --- | --- | --- | --- |
| inquiry | `msgType=10010`, `originalData.cardType=6`, plus a verified inquiry template-data predicate | product `{imageUrl, title}`, `purchaseQuantity {value, unit}`, `requirementText`, `inquiryReference`, `actions[{label, available}]` | full inquiry-card history/event view, including the buyer's visible request and quote/detail affordances as display-only state | hidden encrypted IDs, callbacks, click URLs/params, raw `params`, `from`, `to`, `sign` |
| product | a second sample confirming renderer `cardType=54`, `msgType=101`, and a verified template-data predicate | optional store badge/logo, `product {imageUrl, title, sourceUrl, productId}`, `priceDisplay`, `minimumOrder {value, unit}`, `serviceBadges[]` | full product-card history/event view and safe read-only product reference | raw source text, query/credential-bearing URL, seller/contact object, navigation/action objects |
| order | `msgType=10010`, `originalData.cardType=9`, template predicate `{cardTitle, productInfoList, orderStatusText, shippingAddress}` | `title`, `products[{imageUrl, title}]`, `productCount`, `status {code, text}`, `payment {totalDisplay, discountDisplay}`, `delivery {shippingAddress, methodLabel, dateLabel}`, `action {label, status}` | full order-card history/event view, including visible shipping information, without executable order behavior | `orderAction.actionParams`, trace/click data, callbacks, raw `params`, Base64, customer/contact objects |
`imageUrl` is `string | null`: it is emitted only after a card-image URL validator accepts HTTPS, an allowlisted host, and no credentials/query/fragment; otherwise the rest of the card is retained with `null` image. All visible text is bounded, control-character-checked plain text. `shippingAddress` and `requirementText` are designated sensitive rendered-card content: they are stored and delivered only through the existing authorized account/conversation read scope, never added to diagnostics or general logs.
Common Fiber fields serve only association: `messageId` joins the base message, `conversationCode` proves its scope, and `messageType`/`sendTime` cross-check it. `msgType` and the two card-type values are classification guards; none becomes consumer-visible card content. Ledger-only `contentFingerprint`, `observedAtMs`, terminal status and conflict metadata exist only for duplicate suppression, late-ACK matching and recovery.
The rendered-card contract owns exact guards, clone/equality function and canonical fingerprint tuple; individual readers must not form their own JSON fingerprints.
## 3. Bridge, local ledger and ACK
### 3.1 New envelope and coordinator
- Add one exact page-bridge envelope (`onetalk.page.rendered-card-observed`) containing a nonempty array of typed normalized observations plus `channelAccountId`. It is separate from `onetalk.page.observed`; the latter remains the base-message flow.
- ISOLATED verifies source/origin/direction and forwards without interpreting the payload. The Service Worker decodes it with the page-bridge model, then hands it to the rendered-card coordinator.
- Add `rendered.card.observed` and `rendered.card.ack` plugin wire frames. Bump `ONETALK_PROTOCOL_VERSION` from 7 to 8 and teach the central frame type lists, connection-direction checks and exact payload decoder about the new family. There is no permissive old-protocol fallback.
- The observed payload contains only `channelAccountId`, `conversationId`, `messageId`, validated rendered content, its fingerprint and `observedAtMs`. It does not carry `sentAtMs`, direction, participants, read status, message status, history generation or conversation activity values.
### 3.2 IndexedDB record
Database version advances from 8 to 9. `ensureSyncStores` creates `onetalk_rendered_card_ledger`; upgrades from v8 only add this store and preserve every existing store. The record uses the existing `candidateKey(channelAccountId, conversationId, messageId)`:
```ts
type RenderedCardLedgerRecord = {
key: string;
channelAccountId: string;
conversationId: string;
messageId: string;
content: OneTalkRenderedCardContent;
contentFingerprint: string;
observedAtMs: number;
status: "pending_ack" | "confirmed" | "rejected";
requestId?: string;
firstObservedAt: number;
updatedAt: number;
confirmedAt?: number;
rejectedAt?: number;
rejectionCode?: "base_message_missing" | "content_conflict" | "invalid_card";
};
```
The state owner is a dedicated rendered-card coordinator, not the generic sync engine and not a page-local `Set`.
| Input/state | Result |
| --- | --- |
| same key + same validated content | keep existing terminal/pending record; update observation time only |
| same key + different validated content before terminal ACK | keep first pending and record `content_conflict` diagnostic; do not replace outbound payload |
| same key + different validated content after confirmed | keep confirmed first content; record terminal conflict diagnostic |
| matching accepted or duplicate ACK | atomically mark matching pending fingerprint/observation confirmed |
| matching conflict/rejected ACK | atomically mark matching pending rejected; no automatic retry |
| missing/unconfirmed base candidate | retain card record pending locally; do not send until base candidate confirms |
| worker restart/reconnect | list `pending_ack`, verify base candidate confirmation, resend exact durable snapshot |
The coordinator uses the same commit fence as other Service Worker flows: durable write completes before wire send; after each await it verifies that the coordinator, connection generation and pending record still match; a stale ACK cannot modify a replacement record.
## 4. Bright storage and conflict semantics
### 4.1 Schema
Add `onetalk_rendered_card_content` rather than changing `onetalk_message.content`.
```text
primary key: channel_account_id + conversation_id + message_id
rendered_card_content: JSONB, exact OneTalkRenderedCardContent
rendered_card_content_fingerprint: TEXT
rendered_card_observed_at_ms: BIGINT
first_confirmed_at / last_observed_at: TIMESTAMPTZ
conflict_count: INTEGER
last_conflicting_fingerprint: TEXT NULL
last_conflict_observed_at_ms: BIGINT NULL
```
The table holds no workspace/binding/device copy; those remain on the base fact. Every source field and migration SQL receives matching safe comments. There is no foreign-key-driven implicit behavior: the repository first verifies the base message under the same account/conversation scope, then writes this row in one guarded transaction. The base-message read is verification only: no `onetalk_message` or `onetalk_conversation` row is updated. History reset explicitly clears the matching rendered-card rows in its existing transaction.
### 4.2 Ingest result
`storeRenderedCardContent` is a repository/service operation distinct from `insertMessage`, and must not call `observeMessage`/`observeMessages`:
1. validate the exact shared frame/content and require base message existence;
2. lock/read the card row by complete message key;
3. insert the first normalized snapshot and return `accepted`;
4. if content and fingerprint are semantically equal, update only `lastObservedAt` and return `duplicate`;
5. if the fingerprint/content differs, increment conflict metadata without overwriting content and return `conflict`;
6. commit, then ACK, then only for `accepted` publish `message.updated` with the effective message.
It never inserts/updates the base message, and never updates `sent_at_ms`, base `last_observed_at`, `last_message_at_ms`, `last_contact_time_ms`, `message_count`, `latest_message_id`, checkpoint, anchor or history cursor. `rendered_card_observed_at_ms`, supplement `last_observed_at` and conflict timestamps belong only to the enrichment row.
Fingerprint is a stable contract-owned canonical tuple, not equality by JSON text. The server additionally compares validated snapshots so a fingerprint collision cannot silently be treated as duplicate.
## 5. Read and event projection
- `read-repository.ts` retains its base-message page query. It derives unique `[channelAccountId, conversationId, messageId]` keys, reads matching rendered-card rows in a second scoped query, rejects duplicate enrichment keys, and combines via a full-key `Map` without changing ordering/cursor behavior.
- `effectiveContent = renderedCardContent ?? content` is a controlled union of base and rendered-card content, applied by one projection function used by history HTTP/internal reads and live event construction. No enrichment preserves existing output byte-for-byte at the model level.
- Add `message.updated` as a Mind-page-only frame with the same `OneTalkCenterMessage` payload shape as `message.created`. It reuses the base message's original `sentAtMs`; the publisher serializes it on the existing account queue and targets only exact authorized scope.
- Base accepted result continues to send one `message.created`. Rendered-card accepted result sends one in-place `message.updated`; it must not publish `conversation.updated`, move the conversation, increment a count or change history cursor/order. Duplicate, conflict, rejected and no-Mind-connection cases do not manufacture other events.
## 6. Failure, migration and rollout boundaries
- Any missing Fiber itemData, identity mismatch, unsupported card, raw/schema validation failure, bridge decode failure, IndexedDB failure, base-message absence, transaction/commit-guard failure or protocol mismatch fails closed; it emits no success ACK or Mind update.
- The DB migration is additive. It must be generated from the Drizzle schema, include SQL comments, be applied by the existing migration job only, and never edit historical migration files.
- The extension IDB v8→v9 upgrade is additive and must not clear current messages, candidates, checkpoints, profiles or bootstrap state.
- Product remains disabled until the second live runtime sample proves the expected Fiber evidence and produces a safe canonical product URL; this is an explicit release gate, not a DOM fallback.
@@ -0,0 +1,7 @@
{"file":".trellis/spec/project/architecture.md","reason":"Defines single-owner boundaries, async sequencing, type ownership, and required project-level checks for this cross-layer implementation."}
{"file":".trellis/spec/project/async-state-boundaries.md","reason":"Defines durable write, ACK, publish, retry, late-ACK, and lifecycle requirements for the card ledger."}
{"file":".trellis/spec/project/structured-value-equality.md","reason":"Defines canonical snapshot equality and conflict handling for rendered-card fingerprints."}
{"file":".trellis/spec/project/database-query-composition.md","reason":"Requires scoped reads plus in-memory full-key composition instead of SQL JOIN for effective content."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Defines MAIN/ISOLATED/Service Worker ownership, normalized content boundary, and message identity/ACK path."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md","reason":"Defines IndexedDB durable-first state, restart recovery, ACK behavior, and message candidate boundaries."}
{"file":".trellis/spec/server/backend/database-guidelines.md","reason":"Defines Drizzle migration, comments, transaction, message idempotency, and commit-ACK-publish contracts."}
@@ -0,0 +1,48 @@
# React 卡片监测实施计划
## Delivery shape
One cross-layer task is retained rather than split into child tasks: the normalized card content, wire protocol, local ACK ledger, server transaction, history projection and Mind event must agree atomically. A partial extension-only or server-only delivery would create a protocol/state orphan.
## Ordered work
1. Capture and sanitize live inquiry and second product-card Fiber samples; record field paths/types for every visible screenshot field, then add fixtures. Do not enable either contract until this evidence passes.
2. Extend `packages/onetalk-contract` with the rendered-card content union, rendered-card observation/ACK frame family, exact payload guards, `message.updated`, shared content clone/equality/fingerprint helpers, and safe image/text validators. Bump wire protocol 7→8 and update central frame unions/exports/decoders.
3. Add `apps/chrome-extension/src/onetalk/main-page/card-observer/`: narrow DOM mount trigger, bounded identity Fiber adapter, bounded template-data reader, typed rendered-content normalizer, pagehide/root-rebind lifecycle, and diagnostics. Reuse existing product URL normalization and order-summary validation where compatible, but do not treat their smaller base-content projection as the rendered-card schema.
4. Extend page-bridge model/main/isolated and Service Worker runtime routing with the dedicated rendered-card envelope. Keep ISOLATED transport-only and retain base `onetalk.page.observed` behavior unchanged.
5. Add the v9 IndexedDB rendered-card ledger to `service-worker/storage.ts`, plus a dedicated coordinator that enforces durable-first write, base-candidate-confirmed gating, exact ACK matching and restart recovery. Do not put this state in generic candidate records.
6. Add server schema/model/repository/service support for `onetalk_rendered_card_content`; generate and review additive Drizzle migration and comments. Implement first-content acceptance, semantic duplicate, no-overwrite conflict metadata, base-message absence and reset cleanup through a dedicated supplement method that never calls the base message observation service.
7. Extend plugin router/flow, registry and Mind publisher for rendered-card observed/ack and `message.updated`. Keep `commit -> ACK -> publish` under the existing guard/fence and publish only acceptance.
8. Update read repository/projection and HTTP/internal history paths to perform scoped base/enrichment reads plus in-memory full-key composition. Share the effective-content projection with the live update event.
9. Add focused tests, then run package/root verification and a real Chrome smoke for each available card kind. Product smoke remains blocked until its second sample exists.
## Test matrix
| Layer | Required cases |
| --- | --- |
| contract | exact rendered-card frames, protocol v8 rejection, full inquiry/product/order rendered schemas, image/text privacy validators, fingerprint canonicalization, snapshot mismatch, `message.updated` frame |
| MAIN reader | every visible screenshot field for inquiry/product/order, card 12 ignored, unknown/missing Fiber/template data, dynamic Fiber property, message/conversation mismatch, direction/time validation, root replacement/pagehide |
| page bridge/SW | exact envelope direction/origin checks, no raw fields, durable-write-before-send, duplicate mutation, pending recovery, late/mismatched ACK, conflict terminal state, base-candidate gate |
| IndexedDB | v8→v9 preserves all prior stores, key isolation by account/conversation/message, repeated mount dedup, restart resend, no alternate-content overwrite |
| server domain/repository | missing base reject, first accept, equal duplicate, different conflict metadata/no overwrite, transaction guard failure, scoped reset cleanup, source context preservation; assert no mutation to base message `sentAtMs`/`lastObservedAt` or conversation `lastMessageAtMs`/`lastContactTimeMs`/count/anchor |
| PostgreSQL | generated migration/comment round-trip, table/check/index, accepted/duplicate/conflict rows, reset cleanup, two scoped reads plus no JOIN effective projection |
| read/event | no enrichment returns base content; enrichment overrides only view; history and `message.updated` match while preserving base `sentAtMs`, cursor and ordering; `message.created` once; no conversation move/update; exact scope and no event for duplicate/conflict/reject |
| runtime | browser inspection of inquiry/order and product when sample available; verify normalized observations only, no raw fields or duplicate network frames |
## Validation commands
1. Targeted Node tests for changed contract, card reader, bridge, storage, coordinator, server repository/read/publisher paths. Backend unit commands use a 60-second timeout.
2. `pnpm format:check`
3. `pnpm typecheck`
4. `pnpm test`
5. `pnpm build`
6. `pnpm --filter @trade-message-center/server db:generate` then `db:check`; with configured `TEST_DATABASE_URL`, run focused PostgreSQL migration/integration tests.
7. `git diff --check`; before commit run GitNexus impact/detect-changes and inspect affected flows.
8. Chrome smoke on the authenticated OneTalk page without clicks/reloads: verify the card reader produces only the approved normalized shape and that repeated React render does not cause a second successful upload.
## Risk gates and rollback
- Do not start product code until the second live sample is archived as a sanitized fixture and validates the safe URL path.
- A protocol v8 deployment requires extension/server contract parity. Mismatched peers reject at the existing hello/version boundary; there is no dual wire behavior.
- If database migration validation fails, do not deploy the schema/extension pair. The migration is additive; rollback disables the new extension coordinator/event consumer while preserving base messages and the new inert enrichment rows.
- Any discovered requirement to update order status is a follow-up task, not a conditional overwrite in this task.
@@ -0,0 +1,79 @@
# React 卡片监测
## Goal
将 OneTalk 已挂载消息组件中“基础同步无法直接取得”的可见卡片业务内容安全补全到既有消息事实:商品、询盘与订单只在 MAIN world 读取和归一化;扩展与服务端分别建立独立补全账本;Mind 历史读取与实时事件返回补全后的有效内容,同时保留基础 `content` 不可变。
## Confirmed runtime evidence
- 当前真实 OneTalk 页面中,卡片消息节点可沿 React Fiber 上溯至 `memoizedProps.itemData`;同一对象提供 `messageId`number)、`conversationCode`string)、`messageType``msgType``originalData` 与发送时间。询盘页已验证 `conversationCode` 等于唯一选中 `.contact-item-container.selected[data-cid]`
- 事实身份固定为 `channelAccountId + conversationId + messageId``channelAccountId` 由现有 MAIN page context 读取;`conversationId` 来自经校验的 `conversationCode`;禁止使用 DOM 文本、顺序、时间或 CSS class 合成/猜测身份。
- 已实测 `originalData.cardType`:询盘 `6`、订单 `9`、文件 `12`。外层 `msgType: 10010` 不足以分类,文件必须严格排除。商品当前仅有一份真实样本:渲染器 props `cardType: 54`,基础项为接收侧 `msgType: 101`;尚不能视为稳定协议。
- 订单卡已验证:卡片模板的 React Fiber `memoizedProps.data`(不是 `itemData.originalData.params`)包含 `productInfoList/productList` 的商品图和名称、`productAmount``orderStatusText``shouldPayAmount``shippingAddress``cardTitle`、交付/优惠/备注展示字段,以及只读 `orderAction`。此前 decoder 只取得其中的金额、状态和 action 摘要,遗漏了截图中的商品与收货信息。
- 现有消息同步账本和 Bright 消息事实都以同一复合键去重;首次事实 `commit -> plugin ACK -> message.created`,重复不会再发布。现有基础 `content` 是版本化、归一化 JSON,且不保存 raw content、`params`、签名、Base64 或 token。
## Requirements
### R1. Fiber-only card fact reader
- MutationObserver 只负责发现新挂载的 `.message-item-wrapper`;消息身份来自关联 Fiber `itemData`,二次业务内容来自匹配卡片模板的受限 Fiber `props.data`。不得从 DOM 文案、节点顺序或 class 猜测任何字段。
- Reader 必须在 MAIN world 对 `messageId``conversationCode``msgType` 与相应 `originalData` 路径逐项校验,并确认读取前后唯一选中会话未变化;再按卡片类型从允许的模板 `data` 路径抽取可见内容。基础消息的方向、`sentAtMs` 与参与者信息不由此路径读取或修改。
- 任一身份或类型条件不成立时,丢弃该节点并记录最小、无敏感内容的诊断;不得退回 DOM 文案、顺序或 class 猜测。
### R2. Complete rendered-card contracts and field boundary
- Secondary content is a new shared `OneTalkRenderedCardContent` union, stored separately from the immutable base `OneTalkMessageContent`. It is not an unbounded copy of Fiber props: only business fields actually rendered inside the card are projected.
- Identity fields read from Fiber are never content fields: `channelAccountId + conversationCode + messageId` form the association/dedup key; `messageType` and `sendTime` are cross-checks; `msgType`, source `cardType` and renderer `cardType` are classifier guards.
| Card | Required evidence | Persisted rendered content | Purpose |
| --- | --- | --- | --- |
| inquiry | `msgType=10010`, `originalData.cardType=6`; a real template-data sample before enabling | product image reference, product title, purchase quantity/value + unit, full displayed detailed requirement text, displayed inquiry reference ID, visible action labels/availability | reconstruct the complete inquiry card in history/UI and make the buyer requirement available to authorized consumers; action data remains display-only |
| product | second live renderer-`cardType=54` sample plus template-data shape | product/store image reference, product title, price/range display, minimum order quantity + unit, displayed service/return badges, canonical product reference (`sourceUrl`, `productId`) | reconstruct the product card and provide a safe product reference; no automatic navigation |
| order | `msgType=10010`, `originalData.cardType=9`, template `data` shape | card title; product count; ordered product summaries `{imageUrl, title}`; order status code/text; displayed payable/discount/total amounts; displayed delivery/shipping labels; full displayed shipping address; read-only action label/status | reconstruct the order card exactly enough for authorized history/UI; the address is business-content display, not a scope/authorization field; no action execution |
- Screenshot-derived inquiry/product fields are target requirements, not yet verified Fiber paths. Each needs a sanitized live schema fixture before release. Order fields listed above are already verified as template-data paths in the current runtime.
- Every user-visible string is bounded, control-character-checked and preserved as typed content rather than raw HTML. Image references must be HTTPS, credential/query/fragment-free and pass a dedicated URL validator; otherwise the text/count data is still captured but the image field is `null`.
- Visible action labels may be stored; action URLs, action params, callbacks, EventBus objects, template context and click-trace data never cross MAIN world.
- Never collect, bridge, persist, log or publish: raw `content`; full `originalData`; full raw `params`; hidden encrypted IDs not displayed by the card; `from`/`to`; `sign`; Base64; unapproved URL query/fragment/credentials; React functions, EventBus objects or DOM/Fiber references.
The ledger—not rendered content—stores `contentFingerprint`, `observedAtMs`, ACK status, first/last-seen time and conflict metadata. Their only purpose is duplicate suppression, exact late-ACK matching, recovery and diagnostics. `contentFingerprint` never replaces semantic equality of two validated snapshots.
### R3. Extension durable card ledger
- 保留既有消息 candidate/checkpoint 账本不变;新增独立 rendered-card ledger,以消息复合键、规范化内容指纹、`pending_ack | confirmed | rejected`、观察时间和必要终态时间记录补全上传。
- 卡片仅在对应基础消息 candidate 已确认后发送。重渲染、虚拟列表重挂、Service Worker 重启、断线和 ACK 丢失都从 ledger 中恢复 pending,而非重新依赖页面内存。
- 相同复合键和相同内容指纹只上传一次;服务端明确 duplicate 后本地确认。服务端 conflict 使本地成为 terminal rejected 并保留最小诊断,不能无限重试或覆盖本地已确认内容。
### R4. Server-side independent enrichment
- 基础 `onetalk_message.content` 继续由现有一次性消息同步写入,永不被 React 补全覆盖。
- 新增独立 rendered-card 内容事实表,记录 exact `OneTalkRenderedCardContent``renderedCardContentFingerprint``renderedCardObservedAt` 及冲突元数据,主键同基础消息复合键。仅在同一基础消息已存在时可写入。
- 首个经验证的补全写入 accepted;相同内容为 duplicate;不同内容记录 conflict(包括冲突计数、最后冲突指纹/时间)且不覆盖首个已确认补全。订单的动态状态更新不在本期隐式允许。
- 服务端以同一授权 scope 和完整复合键分别读取基础消息与补全事实,在内存中计算 `effectiveContent = renderedCardContent ?? content`;不得增加 SQL/Drizzle JOIN。
- rendered-card 接口不得调用 `observeMessage``observeMessages`。除补全表本身外,它不得插入、更新或触发更新 `onetalk_message``onetalk_conversation`、candidate/checkpoint 或 history anchor;特别是不得改写 `sent_at_ms`、基础 `last_observed_at``last_message_at_ms``last_contact_time_ms``message_count``latest_message_id`、分页 cursor 或排序。
### R5. Protocol, reads and Mind events
- 建立专用 rendered-card observed/ack wire family 和精确 decoder;升级 OneTalk wire protocol,旧协议双方 fail closed。
- 历史 HTTP/内部读取和实时 Mind WebSocket 均返回 `effectiveContent = renderedCardContent ?? content`;其公开类型是 base 与 rendered-card content 的受控联合。
- 基础消息首次 accepted 后只发布 `message.created`;补全首次 accepted 后只发布 `message.updated`duplicate、conflict、rejected 均不发布。发布顺序仍为 server commit -> plugin ACK -> authorized exact-scope Mind event。
- `message.updated` 仅替换已有消息的 effective content;它携带基础消息原有的 `sentAtMs`,但不改变历史位置、会话活动时间、消息计数或 `conversation.updated` 的 move-to-top 语义。
## Acceptance criteria
- [ ] 三类卡各有可审计的 `Fiber path -> rendered content` 合同、真实判别证据、字段白名单、字段拒绝规则和 targeted tests;商品与询盘规则各有第二份独立运行时样本,否则不启用。
- [ ] 任意一次卡片 observation 都能证明其复合身份,且会话切换/重新渲染/虚拟列表重挂不会错配或重复上传。
- [ ] 本地 IndexedDB 升级新增独立 ledger,不清理现有 v8 账本;在重启、重复 Mutation、ACK 重放/丢失、server duplicate/conflict 下有确定终态。
- [ ] PostgreSQL migration 新增独立补全事实表、完整注释和索引;重复、冲突、基础消息缺失、事务/commit-guard 失败均有可验证语义。
- [ ] 每个 rendered-card accepted/duplicate/conflict/rejected 回归均断言基础消息及会话的 `sent_at_ms``last_observed_at``last_message_at_ms``last_contact_time_ms``message_count`、anchor/cursor 与排序完全不变。
- [ ] history read 与 `message.updated` 返回相同 effective content,原基础 content 保持不变;无补全记录时保持原响应。
- [ ] `message.created` 不因补全再次发布;补全 accepted 仅一次 `message.updated`,并只发给当前授权的精确 Mind scope。
- [ ] 截图中可见的完整询盘需求、商品/订单摘要和订单收货地址可按受控内容合同返回给授权消费者;未桥接、持久化、日志化或发布 raw `content``originalData.params`、签名、Base64、隐藏加密 ID、`from`/`to` 或未经批准 URL。
## Out of scope
- 不修改 OneTalk 页面/React 状态,不主动点击、翻页或请求额外业务接口。
- 不改变既有历史同步、消息 candidate/checkpoint、发送确认或基础消息去重语义。
- 不将二次补全伪装为新消息、重新观察、历史分页或会话活动;不更新任何基础消息/会话时间字段。
- 不采集文件、图片、文本、客户名片或未知卡。
- 不做订单动态字段覆盖;该能力需另定义允许更新字段、内容版本和事件语义。
@@ -0,0 +1,26 @@
{
"id": "react-card-monitor",
"name": "react-card-monitor",
"title": "React 卡片监测",
"description": "规划商品、订单与询盘卡片的 React Fiber 采集、字段合同和 IndexedDB 去重。",
"status": "in_progress",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-14",
"completedAt": null,
"branch": "dev",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -0,0 +1,284 @@
// 从 OneTalk React Fiber 白名单投影订单渲染卡片
import {
createOneTalkRenderedCardContentFingerprint,
isOneTalkRenderedCardImageUrl,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import { readConversationSelection } from "../page-context.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { OneTalkPageRenderedCardBaseEvidence } from "../../page-bridge/model.ts";
type FiberRecord = {
return?: FiberRecord | null;
child?: FiberRecord | null;
sibling?: FiberRecord | null;
memoizedProps?: unknown;
};
type ItemData = {
messageId: string;
conversationCode: string;
messageType: "rec" | "send";
sendTime: number;
msgType: number;
originalData: Record<string, unknown>;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const text = (value: unknown): string | null =>
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= 64 * 1024 &&
!/[\u0000-\u001f\u007f]/u.test(value)
? value
: null;
const nullableText = (value: unknown): string | null => (value === null ? null : text(value));
const imageUrl = (value: unknown): string | null =>
isOneTalkRenderedCardImageUrl(value) ? value : null;
const scalarId = (value: unknown): string | null =>
typeof value === "string" && value.trim().length > 0
? value
: typeof value === "number" && Number.isSafeInteger(value)
? String(value)
: null;
const nonNegativeInteger = (value: unknown): number | null =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0 ? value : null;
const fiberFor = (node: Node): FiberRecord | null => {
for (const key of Object.keys(node)) {
if (key.startsWith("__reactFiber$")) {
const fiber = (node as unknown as Record<string, unknown>)[key];
if (isRecord(fiber)) return fiber as FiberRecord;
}
}
return null;
};
const itemDataFor = (fiber: FiberRecord): ItemData | null => {
let cursor: FiberRecord | null | undefined = fiber;
for (let depth = 0; cursor && depth < 60; depth += 1) {
const current: FiberRecord = cursor;
cursor = current.return;
const props = current.memoizedProps;
if (!isRecord(props) || !isRecord(props.itemData)) continue;
const item = props.itemData;
const messageId = scalarId(item.messageId);
const conversationCode = text(item.conversationCode);
const messageType =
item.messageType === "rec" || item.messageType === "send" ? item.messageType : null;
const sendTime = nonNegativeInteger(item.sendTime);
if (
!messageId ||
!conversationCode ||
!messageType ||
sendTime === null ||
nonNegativeInteger(item.msgType) === null ||
!isRecord(item.originalData)
)
return null;
return {
messageId,
conversationCode,
messageType,
sendTime,
msgType: nonNegativeInteger(item.msgType)!,
originalData: item.originalData,
};
}
return null;
};
const templateDataFor = (fiber: FiberRecord): Record<string, unknown> | null => {
const pending: FiberRecord[] = [fiber];
for (let index = 0; index < pending.length && index < 120; index += 1) {
const current = pending[index]!;
const props = current.memoizedProps;
if (isRecord(props) && isRecord(props.data)) {
const data = props.data;
if (
text(data.cardTitle) &&
Array.isArray(data.productInfoList ?? data.productList) &&
text(data.orderStatusText) &&
text(data.shippingAddress)
)
return data;
}
if (current.child) pending.push(current.child);
if (current.sibling) pending.push(current.sibling);
}
return null;
};
const productFor = (value: unknown): { imageUrl: string | null; title: string } | null => {
if (!isRecord(value)) return null;
const title = text(value.title) ?? text(value.productTitle) ?? text(value.name);
if (!title) return null;
return {
imageUrl: imageUrl(value.imageUrl) ?? imageUrl(value.productImage) ?? imageUrl(value.image),
title,
};
};
const orderContentFor = (data: Record<string, unknown>) => {
const products = data.productInfoList ?? data.productList;
if (!Array.isArray(products)) return null;
const projected = products.map(productFor);
const title = text(data.cardTitle);
const statusText = text(data.orderStatusText);
const shippingAddress = text(data.shippingAddress);
const totalDisplay = text(data.shouldPayAmount) ?? text(data.productAmount);
if (
!title ||
!statusText ||
!shippingAddress ||
!totalDisplay ||
projected.some((item) => item === null)
)
return null;
const action = isRecord(data.orderAction) ? data.orderAction : {};
return {
version: 1 as const,
kind: "rendered_order" as const,
title,
products: projected as { imageUrl: string | null; title: string }[],
productCount: nonNegativeInteger(data.productCount) ?? projected.length,
status: { code: scalarId(data.orderStatus), text: statusText },
payment: { totalDisplay, discountDisplay: nullableText(data.discountAmount) },
delivery: {
shippingAddress,
methodLabel: nullableText(data.shippingMethodText),
dateLabel: nullableText(data.deliveryTimeText),
},
action: {
label: nullableText(action.label) ?? nullableText(action.name),
status: nullableText(action.status),
},
};
};
const observationFor = (
pageWindow: OneTalkPageWindow,
wrapper: Element,
): OneTalkRenderedCardObservation | null => {
const before = readConversationSelection(pageWindow);
if (before.kind !== "single") return null;
const fiber =
fiberFor(wrapper) ??
Array.from(wrapper.querySelectorAll("*"))
.map(fiberFor)
.find((value): value is FiberRecord => value !== null);
if (!fiber) return null;
const item = itemDataFor(fiber);
if (
!item ||
item.conversationCode !== before.conversationId ||
item.msgType !== 10010 ||
item.originalData.cardType !== 9
)
return null;
const template = templateDataFor(fiber);
if (!template) return null;
const content = orderContentFor(template);
const after = readConversationSelection(pageWindow);
if (!content || after.kind !== "single" || after.conversationId !== before.conversationId)
return null;
return {
conversationId: item.conversationCode,
messageId: item.messageId,
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: Date.now(),
};
};
const messageListRootFor = (
document: NonNullable<OneTalkPageWindow["document"]>,
): Element | null => {
const firstMessage = document.querySelectorAll(".message-item-wrapper")[0];
if (!firstMessage) return null;
let parent = firstMessage.parentElement;
while (parent) {
if (parent.querySelectorAll(".message-item-wrapper").length > 1) return parent;
parent = parent.parentElement;
}
return firstMessage.parentElement;
};
/** 安装仅以真实 mount 为触发的 Fiber card reader;未验证类别一律不上传。 */
export const installOneTalkRenderedCardObserver = (
pageWindow: OneTalkPageWindow,
sink: (
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
) => void,
): void => {
const document = pageWindow.document;
if (!document || !pageWindow.MutationObserver) return;
const seen = new WeakSet<Element>();
let disposed = false;
const inspect = (wrapper: Element): void => {
if (seen.has(wrapper)) return;
seen.add(wrapper);
setTimeout(() => {
if (disposed) return;
const observation = observationFor(pageWindow, wrapper);
if (!observation) return;
const fiber =
fiberFor(wrapper) ??
Array.from(wrapper.querySelectorAll("*"))
.map(fiberFor)
.find((value): value is FiberRecord => value !== null);
const item = fiber ? itemDataFor(fiber) : null;
if (!item) return;
sink(
[observation],
[
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: item.messageType === "rec" ? "received" : "sent",
sentAtMs: item.sendTime,
},
],
);
}, 0);
};
let root: Element | null = null;
let observingDocument = false;
const observer = new pageWindow.MutationObserver((records) => {
if (disposed) return;
if (messageListRootFor(document) !== root) {
bind();
return;
}
for (const record of records) {
if (root && record.target !== root && !root.contains(record.target)) continue;
for (const node of Array.from(record.addedNodes)) {
if (!(node instanceof Element)) continue;
if (node.matches(".message-item-wrapper")) inspect(node);
for (const wrapper of Array.from(node.querySelectorAll(".message-item-wrapper")))
inspect(wrapper);
}
}
});
const bind = (): void => {
if (disposed) return;
const nextRoot = messageListRootFor(document);
if (nextRoot === root && (root !== null || observingDocument)) return;
observer.disconnect();
root = nextRoot;
if (!root) {
observingDocument = true;
observer.observe(document as unknown as Node, { childList: true, subtree: true });
return;
}
observingDocument = false;
for (const wrapper of Array.from(root.querySelectorAll(".message-item-wrapper")))
inspect(wrapper);
observer.observe(root, { childList: true, subtree: true });
if (root.parentElement) observer.observe(root.parentElement, { childList: true });
};
bind();
pageWindow.addEventListener("popstate", bind);
pageWindow.addEventListener("hashchange", bind);
pageWindow.addEventListener("pagehide", () => {
disposed = true;
observer.disconnect();
});
};
@@ -5,7 +5,7 @@ export type OneTalkAccountId = string | number;
export type OneTalkPageWindow = {
location: Pick<Location, "href">;
document?: Pick<Document, "querySelectorAll" | "addEventListener">;
addEventListener(type: "pagehide", listener: () => void): void;
addEventListener(type: "pagehide" | "popstate" | "hashchange", listener: () => void): void;
IcbuIM?: unknown;
__tradeMessageCenterOneTalk?: unknown;
/** OneTalk runtime's logged-in account identifier. */
@@ -11,10 +11,12 @@ import { HistoryBootstrapProgressTooltip } from "./current-conversation-history/
import { createSendObservationCorrelator } from "./message-observer/send-observation.ts";
import { installOneTalkContactProfileObserver } from "./contact-observer/entry.ts";
import { installOneTalkCollectionObservers } from "./collection/entry.ts";
import { installOneTalkRenderedCardObserver } from "./card-observer/entry.ts";
import { createOneTalkObservedPublisher } from "./collection/observed-publisher.ts";
import { createOneTalkPageCommandHandler } from "./commands/index.ts";
import {
createOneTalkPageBuyerFactsObservedSink,
createOneTalkPageRenderedCardObservedSink,
createOneTalkPageObservedSink,
createOneTalkPageProfileObservedSink,
installOneTalkMainPageBridge,
@@ -36,6 +38,7 @@ const installOneTalkPageFeatures = (): void => {
);
const observedSink = createOneTalkPageObservedSink(window);
const buyerFactSink = createOneTalkPageBuyerFactsObservedSink(window);
const renderedCardSink = createOneTalkPageRenderedCardObservedSink(window);
const publish = createOneTalkObservedPublisher(observedSink, sendObservation.observe);
const onCommand = createOneTalkPageCommandHandler(window, {
sendObservation,
@@ -51,6 +54,7 @@ const installOneTalkPageFeatures = (): void => {
(unbound) => bindingStatus.update(unbound),
);
installOneTalkCollectionObservers(window, publish, buyerFactSink);
installOneTalkRenderedCardObserver(window, renderedCardSink);
};
installOneTalkPageFeatures();
@@ -15,6 +15,7 @@ import { traceOneTalkObservedMessages } from "../diagnostics/message-trace.ts";
import type {
OneTalkBuyerFact,
OneTalkContactProfile,
OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import {
createOneTalkPageCommandResultMessage,
@@ -32,8 +33,10 @@ import {
type OneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
createOneTalkPageBuyerFactsObservedMessage,
createOneTalkPageRenderedCardObservedMessage,
ONE_TALK_PAGE_BRIDGE_RECONNECT_SIGNAL,
type PageCommandResult,
type OneTalkPageRenderedCardBaseEvidence,
} from "./model.ts";
export type OneTalkPageCommandHandler = (
@@ -218,6 +221,30 @@ export const createOneTalkPageObservedSink = (
};
};
/** 创建只发送经 MAIN 白名单投影的渲染卡片补全 sink。 */
export const createOneTalkPageRenderedCardObservedSink = (
pageWindow: OneTalkMainPageBridgeWindow,
): ((
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
) => void) => {
const origin = pageOrigin(pageWindow);
return (observations, baseEvidence: OneTalkPageRenderedCardBaseEvidence[]) => {
if (!origin || observations.length === 0) return;
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId) return;
postPageMessage(
pageWindow,
origin,
createOneTalkPageRenderedCardObservedMessage(
channelAccountId,
observations,
baseEvidence,
),
);
};
};
/** 创建把历史分页进度发布到 Service Worker 的 MAIN sink。 */
export const createOneTalkPageHistoryProgressSink = (
pageWindow: OneTalkPageBridgeWindow,
@@ -2,12 +2,15 @@
import {
cloneOneTalkBuyerFact,
createOneTalkRenderedCardContentFingerprint,
isPlainRecord,
isOneTalkContactProfile,
isOneTalkMessage,
isOneTalkBuyerFact,
isOneTalkRenderedCardContent,
type OneTalkBuyerFact,
type OneTalkContactProfile,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import type {
ObservedOneTalkMessage,
@@ -79,6 +82,24 @@ export type OneTalkPageBuyerFactsObservedMessage = {
facts: OneTalkBuyerFact[];
};
/** MAIN 已完成白名单投影的渲染卡片补全;不携带 DOM/Fiber/raw props。 */
export type OneTalkPageRenderedCardObservedMessage = {
source: typeof ONE_TALK_PAGE_BRIDGE_SOURCE;
version: typeof ONE_TALK_PAGE_BRIDGE_VERSION;
type: "onetalk.page.rendered-card-observed";
channelAccountId: string;
observations: OneTalkRenderedCardObservation[];
/** MAIN-only proof used to compare against the durable base candidate; never enters wire. */
baseEvidence: OneTalkPageRenderedCardBaseEvidence[];
};
export type OneTalkPageRenderedCardBaseEvidence = {
conversationId: string;
messageId: string;
direction: "received" | "sent";
sentAtMs: number;
};
export type OneTalkPageHistoryProgress = {
conversationId: string;
latestMessageAtMs: number | null;
@@ -125,6 +146,7 @@ export type OneTalkPageMessage =
| OneTalkPageObservedMessage
| OneTalkPageProfileObservedMessage
| OneTalkPageBuyerFactsObservedMessage
| OneTalkPageRenderedCardObservedMessage
| OneTalkPageCommandMessage
| OneTalkPageCommandResultMessage
| OneTalkPageConnectionStatusMessage
@@ -505,6 +527,87 @@ const decodeBuyerFactsObservedMessage = (
};
};
const decodeRenderedCardObservedMessage = (
value: Record<string, unknown>,
): OneTalkPageRenderedCardObservedMessage | null => {
if (
!hasExactKeys(value, [
"source",
"version",
"type",
"channelAccountId",
"observations",
"baseEvidence",
]) ||
typeof value.channelAccountId !== "string" ||
value.channelAccountId.trim().length === 0 ||
!Array.isArray(value.observations) ||
value.observations.length === 0 ||
!value.observations.every(
(observation) =>
isPlainRecord(observation) &&
hasExactKeys(observation, [
"conversationId",
"messageId",
"content",
"contentFingerprint",
"observedAtMs",
]) &&
typeof observation.conversationId === "string" &&
observation.conversationId.trim().length > 0 &&
typeof observation.messageId === "string" &&
observation.messageId.trim().length > 0 &&
typeof observation.contentFingerprint === "string" &&
typeof observation.observedAtMs === "number" &&
Number.isSafeInteger(observation.observedAtMs) &&
observation.observedAtMs >= 0 &&
isOneTalkRenderedCardContent(observation.content) &&
observation.contentFingerprint ===
createOneTalkRenderedCardContentFingerprint(observation.content),
)
)
return null;
const observations = value.observations as Record<string, unknown>[];
const baseEvidence = value.baseEvidence;
if (
!Array.isArray(baseEvidence) ||
baseEvidence.length !== observations.length ||
!baseEvidence.every(
(evidence, index) =>
isPlainRecord(evidence) &&
hasExactKeys(evidence, ["conversationId", "messageId", "direction", "sentAtMs"]) &&
typeof evidence.conversationId === "string" &&
typeof evidence.messageId === "string" &&
(evidence.direction === "received" || evidence.direction === "sent") &&
typeof evidence.sentAtMs === "number" &&
Number.isSafeInteger(evidence.sentAtMs) &&
evidence.sentAtMs >= 0 &&
evidence.conversationId === observations[index]?.conversationId &&
evidence.messageId === observations[index]?.messageId,
)
)
return null;
return {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.rendered-card-observed",
channelAccountId: value.channelAccountId,
observations: observations.map((observation) => ({
conversationId: observation.conversationId as string,
messageId: observation.messageId as string,
content: JSON.parse(JSON.stringify(observation.content)),
contentFingerprint: observation.contentFingerprint as string,
observedAtMs: observation.observedAtMs as number,
})),
baseEvidence: baseEvidence.map((evidence) => ({
conversationId: evidence.conversationId as string,
messageId: evidence.messageId as string,
direction: evidence.direction as "received" | "sent",
sentAtMs: evidence.sentAtMs as number,
})),
};
};
const decodeCommandMessage = (value: Record<string, unknown>): OneTalkPageCommandMessage | null => {
if (
typeof value.requestId !== "string" ||
@@ -593,6 +696,8 @@ export const decodeOneTalkPageMessage = (value: unknown): OneTalkPageMessage | n
return decodeProfileObservedMessage(value);
case "onetalk.page.buyer-facts-observed":
return decodeBuyerFactsObservedMessage(value);
case "onetalk.page.rendered-card-observed":
return decodeRenderedCardObservedMessage(value);
case "onetalk.page.command":
return decodeCommandMessage(value);
case "onetalk.page.command-result":
@@ -683,6 +788,23 @@ export const createOneTalkPageBuyerFactsObservedMessage = (
facts: facts.map(cloneOneTalkBuyerFact),
});
/** 创建只含受控卡片补全的 MAIN 到 ISOLATED envelope。 */
export const createOneTalkPageRenderedCardObservedMessage = (
channelAccountId: string,
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[],
): OneTalkPageRenderedCardObservedMessage => ({
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.rendered-card-observed",
channelAccountId,
observations: observations.map((observation) => ({
...observation,
content: JSON.parse(JSON.stringify(observation.content)),
})),
baseEvidence: baseEvidence.map((evidence) => ({ ...evidence })),
});
/** 创建发送到页面的命令消息。 */
export const createOneTalkPageCommandMessage = (
requestId: string,
@@ -747,7 +869,8 @@ export const isOneTalkPageObservationMessage = (
message.type === "onetalk.page.hello" ||
message.type === "onetalk.page.observed" ||
message.type === "onetalk.page.profile-observed" ||
message.type === "onetalk.page.buyer-facts-observed"
message.type === "onetalk.page.buyer-facts-observed" ||
message.type === "onetalk.page.rendered-card-observed"
);
};
@@ -787,6 +910,7 @@ export const isOneTalkMainToIsolatedMessage = (
| OneTalkPageObservedMessage
| OneTalkPageProfileObservedMessage
| OneTalkPageBuyerFactsObservedMessage
| OneTalkPageRenderedCardObservedMessage
| OneTalkPageCommandResultMessage => {
return isOneTalkPageObservationMessage(message) || isOneTalkPageCommandResultMessage(message);
};
@@ -24,8 +24,10 @@ import {
import {
createOneTalkConversationBootstrapStore,
createOneTalkSyncStore,
createOneTalkRenderedCardLedgerStore,
type OneTalkConversationBootstrapStore,
type OneTalkSyncStore,
type OneTalkRenderedCardLedgerStore,
} from "./storage.ts";
import { createOneTalkContactProfileStore, type OneTalkContactProfileStore } from "./storage.ts";
import {
@@ -38,12 +40,17 @@ import {
type OneTalkBuyerFactCoordinator,
} from "./buyer-fact-coordinator.ts";
import { createOneTalkBuyerFactStore, type OneTalkBuyerFactStore } from "./buyer-fact-store.ts";
import {
createOneTalkRenderedCardCoordinator,
type OneTalkRenderedCardCoordinator,
} from "./rendered-card-coordinator.ts";
export type OneTalkActiveSyncSession = {
bright: OneTalkBrightClient;
engine: OneTalkSyncEngine;
profile?: OneTalkContactProfileCoordinator;
buyer?: OneTalkBuyerFactCoordinator;
renderedCard?: OneTalkRenderedCardCoordinator;
disposeProfileStatus: () => void;
disposeRouter: () => void;
};
@@ -54,6 +61,7 @@ export type OneTalkConfiguredSyncSessionOptions = {
createBootstrapStore?: () => OneTalkConversationBootstrapStore;
createProfileStore?: () => OneTalkContactProfileStore;
createBuyerFactStore?: () => OneTalkBuyerFactStore;
createRenderedCardLedgerStore?: () => OneTalkRenderedCardLedgerStore;
pageRuntime: Pick<OneTalkServiceWorkerRuntime, "routePageCommand">;
onBeforeChange?: () => void;
onStatusChange: () => void;
@@ -93,6 +101,9 @@ export class OneTalkConfiguredSyncSession {
private readonly createBootstrapStore: () => OneTalkConversationBootstrapStore;
private readonly createProfileStore: (() => OneTalkContactProfileStore) | undefined;
private readonly createBuyerFactStore: (() => OneTalkBuyerFactStore) | undefined;
private readonly createRenderedCardLedgerStore:
| (() => OneTalkRenderedCardLedgerStore)
| undefined;
private readonly pageRuntime: Pick<OneTalkServiceWorkerRuntime, "routePageCommand">;
private readonly onBeforeChange: (() => void) | undefined;
private readonly onStatusChange: () => void;
@@ -113,6 +124,7 @@ export class OneTalkConfiguredSyncSession {
private bootstrapStore: OneTalkConversationBootstrapStore | null = null;
private profileStore: OneTalkContactProfileStore | null = null;
private buyerFactStore: OneTalkBuyerFactStore | null = null;
private renderedCardLedgerStore: OneTalkRenderedCardLedgerStore | null = null;
private active: OneTalkActiveSyncSession | null = null;
private currentConfig: OneTalkExtensionConfig | null = null;
private revision = 0;
@@ -132,6 +144,11 @@ export class OneTalkConfiguredSyncSession {
(typeof globalThis.indexedDB === "undefined"
? undefined
: () => createOneTalkBuyerFactStore());
this.createRenderedCardLedgerStore =
options.createRenderedCardLedgerStore ??
(typeof globalThis.indexedDB === "undefined"
? undefined
: () => createOneTalkRenderedCardLedgerStore());
this.pageRuntime = options.pageRuntime;
this.onBeforeChange = options.onBeforeChange;
this.onStatusChange = options.onStatusChange;
@@ -179,6 +196,7 @@ export class OneTalkConfiguredSyncSession {
previous?.engine.dispose();
previous?.profile?.dispose();
previous?.buyer?.dispose();
previous?.renderedCard?.dispose();
previous?.disposeProfileStatus();
previous?.disposeRouter();
previous?.bright.disconnect();
@@ -203,6 +221,7 @@ export class OneTalkConfiguredSyncSession {
};
let profile: OneTalkContactProfileCoordinator | undefined;
let buyer: OneTalkBuyerFactCoordinator | undefined;
let renderedCard: OneTalkRenderedCardCoordinator | undefined;
const bright = this.createBrightClient({
url: config.brightWebSocketUrl,
scope: pluginScope,
@@ -246,16 +265,32 @@ export class OneTalkConfiguredSyncSession {
});
}
const activeBuyer = buyer;
const nextStore = this.store ?? (this.store = this.createStore());
const nextRenderedCardLedgerStore = this.createRenderedCardLedgerStore
? (this.renderedCardLedgerStore ??
(this.renderedCardLedgerStore = this.createRenderedCardLedgerStore()))
: undefined;
if (nextRenderedCardLedgerStore) {
renderedCard = createOneTalkRenderedCardCoordinator({
scope: pluginScope,
ledger: nextRenderedCardLedgerStore,
syncStore: nextStore,
bright,
createRequestId,
onError: reportCurrentError,
});
}
const activeRenderedCard = renderedCard;
const unsubscribeProfileStatus =
activeProfile || activeBuyer
activeProfile || activeBuyer || activeRenderedCard
? bright.subscribeStatus?.((state) => {
if (currentRevision !== this.revision) return;
activeProfile?.handleStatus(state);
if (activeProfile) this.onProfileStatus?.(activeProfile, state);
activeBuyer?.handleStatus(state);
activeRenderedCard?.handleStatus(state);
})
: undefined;
const nextStore = this.store ?? (this.store = this.createStore());
const nextBootstrapStore =
this.bootstrapStore ?? (this.bootstrapStore = this.createBootstrapStore());
const engine = createOneTalkSyncEngine({
@@ -292,6 +327,7 @@ export class OneTalkConfiguredSyncSession {
sync: engine,
...(activeProfile === undefined ? {} : { profile: activeProfile }),
...(activeBuyer === undefined ? {} : { buyer: activeBuyer }),
...(activeRenderedCard === undefined ? {} : { renderedCard: activeRenderedCard }),
send,
rebuild,
});
@@ -304,6 +340,7 @@ export class OneTalkConfiguredSyncSession {
bright.disconnect();
profile?.dispose();
buyer?.dispose();
renderedCard?.dispose();
unsubscribeProfileStatus?.();
unsubscribeRouter();
return false;
@@ -314,6 +351,7 @@ export class OneTalkConfiguredSyncSession {
engine,
...(activeProfile === undefined ? {} : { profile: activeProfile }),
...(activeBuyer === undefined ? {} : { buyer: activeBuyer }),
...(activeRenderedCard === undefined ? {} : { renderedCard: activeRenderedCard }),
disposeProfileStatus: unsubscribeProfileStatus ?? (() => undefined),
disposeRouter: unsubscribeRouter,
};
@@ -11,6 +11,7 @@ import type {
OneTalkContactProfileDiagnostic,
} from "./contact-profile-coordinator.ts";
import type { OneTalkBuyerFactCoordinator } from "./buyer-fact-coordinator.ts";
import type { OneTalkRenderedCardCoordinator } from "./rendered-card-coordinator.ts";
import type { OneTalkBrightConnectionStatus } from "./transport/bright-client.ts";
export type OneTalkPageIdentity = {
@@ -22,6 +23,7 @@ type OneTalkPageRuntimeHostOptions = {
getActiveEngine: () => OneTalkSyncEngine | null;
getActiveProfileCoordinator?: () => OneTalkContactProfileCoordinator | null;
getActiveBuyerFactCoordinator?: () => OneTalkBuyerFactCoordinator | null;
getActiveRenderedCardCoordinator?: () => OneTalkRenderedCardCoordinator | null;
getActiveChannelAccountId?: () => string | null;
getConfigurationEpoch?: () => number;
onProfileDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
@@ -311,6 +313,17 @@ export class OneTalkPageRuntimeHost {
await coordinator.observe(message.facts);
void sender;
},
persistPageRenderedCardObservation: async (
message,
sender,
channelAccountId,
): Promise<void> => {
const coordinator = options.getActiveRenderedCardCoordinator?.();
if (!coordinator || channelAccountId !== this.lastPageIdentity?.channelAccountId)
return;
await coordinator.observe(message.observations, message.baseEvidence);
void sender;
},
onPageMessage: () => undefined,
onPageProfileMessage: () => undefined,
onPageDisconnect: () => {
@@ -321,6 +334,7 @@ export class OneTalkPageRuntimeHost {
options.getActiveEngine()?.handlePageDisconnected();
options.getActiveProfileCoordinator?.()?.handlePageDisconnected();
options.getActiveBuyerFactCoordinator?.()?.handlePageDisconnected();
options.getActiveRenderedCardCoordinator?.()?.handlePageDisconnected();
},
onPageIdentity: async (
_sender: Parameters<
@@ -0,0 +1,186 @@
// 协调渲染卡片 ledger 的 durable-first 发送与精确 ACK
import {
createOneTalkRenderedCardObservedFrame,
type OneTalkFrame,
type OneTalkPluginScope,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import type { OneTalkBrightClient, OneTalkBrightClientState } from "./transport/bright-client.ts";
import type { OneTalkRenderedCardLedgerStore } from "./storage.ts";
import type { OneTalkSyncStore } from "./storage.ts";
import type { OneTalkPageRenderedCardBaseEvidence } from "../page-bridge/model.ts";
export type OneTalkRenderedCardCoordinator = {
observe: (
observations: OneTalkRenderedCardObservation[],
baseEvidence?: OneTalkPageRenderedCardBaseEvidence[],
) => Promise<void>;
handleFrame: (frame: OneTalkFrame) => void;
handleStatus: (state: OneTalkBrightClientState) => void;
handleBaseCandidateProgress: () => void;
handlePageReady: () => Promise<void>;
handlePageDisconnected: () => void;
dispose: () => void;
};
/** 创建独立卡片协调器;基础消息确认前不允许补全上传。 */
export const createOneTalkRenderedCardCoordinator = (options: {
scope: OneTalkPluginScope;
ledger: OneTalkRenderedCardLedgerStore;
syncStore: OneTalkSyncStore;
bright: OneTalkBrightClient;
createRequestId?: (kind: string) => string;
onError?: (error: unknown) => void;
}): OneTalkRenderedCardCoordinator => {
let disposed = false;
let sequence = 0;
const sent = new Map<
string,
{ requestId: string; contentFingerprint: string; observedAtMs: number }
>();
const acknowledging = new Set<string>();
const createRequestId =
options.createRequestId ?? ((kind: string) => `rendered-card-${kind}-${++sequence}`);
const report = (error: unknown): void => {
try {
options.onError?.(error);
} catch {
// Error projection cannot alter durable state.
}
};
const flush = async (): Promise<void> => {
if (disposed || !options.bright.isOnline()) return;
try {
for (const record of await options.ledger.listPending(options.scope.channelAccountId)) {
if (disposed || !options.bright.isOnline() || sent.has(record.key)) continue;
const base = await options.syncStore.getCandidate(
record.channelAccountId,
record.conversationId,
record.messageId,
);
if (
disposed ||
base?.status !== "confirmed" ||
base.message.direction !== record.baseDirection ||
base.message.sentAtMs !== record.baseSentAtMs
)
continue;
const requestId = createRequestId("observed");
const pendingSend = {
requestId,
contentFingerprint: record.contentFingerprint,
observedAtMs: record.observedAtMs,
};
sent.set(record.key, pendingSend);
const durableRecord = await options.ledger.markSent({
channelAccountId: record.channelAccountId,
conversationId: record.conversationId,
messageId: record.messageId,
contentFingerprint: record.contentFingerprint,
observedAtMs: record.observedAtMs,
requestId,
});
if (!durableRecord || disposed || !options.bright.isOnline()) {
if (sent.get(record.key) === pendingSend) sent.delete(record.key);
continue;
}
const sentFrame = options.bright.send(
createOneTalkRenderedCardObservedFrame(
{ connectionType: "plugin", requestId, scope: options.scope },
{
conversationId: record.conversationId,
messageId: record.messageId,
content: durableRecord.content,
contentFingerprint: durableRecord.contentFingerprint,
observedAtMs: durableRecord.observedAtMs,
},
),
);
if (!sentFrame && sent.get(durableRecord.key) === pendingSend)
sent.delete(durableRecord.key);
}
} catch (error: unknown) {
report(error);
}
};
const observe = async (
observations: OneTalkRenderedCardObservation[],
baseEvidence: OneTalkPageRenderedCardBaseEvidence[] = [],
): Promise<void> => {
if (disposed) return;
if (
baseEvidence.length !== observations.length ||
!observations.every(
(observation, index) =>
baseEvidence[index]?.conversationId === observation.conversationId &&
baseEvidence[index]?.messageId === observation.messageId,
)
)
return;
for (const [index, observation] of observations.entries()) {
const evidence = baseEvidence[index]!;
await options.ledger.observe({
channelAccountId: options.scope.channelAccountId,
observation,
baseEvidence: {
direction: evidence.direction,
sentAtMs: evidence.sentAtMs,
},
});
if (disposed) return;
}
await flush();
};
const handleFrame = (frame: OneTalkFrame): void => {
if (disposed || frame.type !== "rendered.card.ack") return;
const key = JSON.stringify([
options.scope.channelAccountId,
frame.payload.conversationId,
frame.payload.messageId,
]);
const pending = sent.get(key);
if (
!pending ||
acknowledging.has(key) ||
pending.requestId !== frame.requestId ||
pending.contentFingerprint !== frame.payload.contentFingerprint ||
pending.observedAtMs !== frame.payload.observedAtMs
)
return;
acknowledging.add(key);
void options.ledger
.markAcknowledged({
channelAccountId: options.scope.channelAccountId,
requestId: frame.requestId,
...frame.payload,
})
.then((matched) => {
if (matched) sent.delete(key);
})
.catch(report)
.finally(() => acknowledging.delete(key));
};
return {
observe,
handleFrame,
handleStatus: (state) => {
if (state.status !== "authenticated") {
sent.clear();
return;
}
void flush();
},
handleBaseCandidateProgress: () => {
if (disposed) return;
setTimeout(() => void flush(), 0);
},
handlePageReady: flush,
handlePageDisconnected: () => undefined,
dispose: () => {
disposed = true;
sent.clear();
acknowledging.clear();
},
};
};
@@ -7,6 +7,7 @@ import type { OneTalkContactProfileCoordinator } from "../contact-profile-coordi
import type { OneTalkSendCommandFlow } from "../flows/send-command-flow.ts";
import type { OneTalkHistoryRebuildFlow } from "../flows/history-rebuild-flow.ts";
import type { OneTalkSyncEngine } from "../sync-engine.ts";
import type { OneTalkRenderedCardCoordinator } from "../rendered-card-coordinator.ts";
type OneTalkBusinessRoute = {
dispatch: (frame: OneTalkFrame) => boolean;
@@ -36,6 +37,10 @@ export const createOneTalkServiceWorkerFrameRouter = (options: {
sync: Pick<OneTalkSyncEngine, "handleServerFrame">;
profile?: Pick<OneTalkContactProfileCoordinator, "handleFrame">;
buyer?: Pick<OneTalkBuyerFactCoordinator, "handleFrame">;
renderedCard?: Pick<
OneTalkRenderedCardCoordinator,
"handleFrame" | "handleBaseCandidateProgress"
>;
send?: Pick<OneTalkSendCommandFlow, "handle">;
rebuild?: Pick<OneTalkHistoryRebuildFlow, "handle">;
}): OneTalkServiceWorkerFrameRouter => {
@@ -43,10 +48,14 @@ export const createOneTalkServiceWorkerFrameRouter = (options: {
defineOneTalkBusinessRoute("anchor.snapshot", (frame) =>
options.sync.handleServerFrame(frame),
),
defineOneTalkBusinessRoute("message.ack", (frame) => options.sync.handleServerFrame(frame)),
defineOneTalkBusinessRoute("messages.ack", (frame) =>
options.sync.handleServerFrame(frame),
),
defineOneTalkBusinessRoute("message.ack", (frame) => {
options.sync.handleServerFrame(frame);
options.renderedCard?.handleBaseCandidateProgress();
}),
defineOneTalkBusinessRoute("messages.ack", (frame) => {
options.sync.handleServerFrame(frame);
options.renderedCard?.handleBaseCandidateProgress();
}),
defineOneTalkBusinessRoute("conversation.ack", (frame) =>
options.sync.handleServerFrame(frame),
),
@@ -57,6 +66,9 @@ export const createOneTalkServiceWorkerFrameRouter = (options: {
options.profile?.handleFrame(frame),
),
defineOneTalkBusinessRoute("buyer.facts.ack", (frame) => options.buyer?.handleFrame(frame)),
defineOneTalkBusinessRoute("rendered.card.ack", (frame) =>
options.renderedCard?.handleFrame(frame),
),
defineOneTalkBusinessRoute("send.command", (frame) => options.send?.handle(frame)),
defineOneTalkBusinessRoute("storage.delete.command", (frame) =>
options.rebuild?.handle(frame),
@@ -13,6 +13,7 @@ import {
type OneTalkPageObservedMessage,
type OneTalkPageProfileObservedMessage,
type OneTalkPageBuyerFactsObservedMessage,
type OneTalkPageRenderedCardObservedMessage,
type PageCommand,
type PageCommandResult,
} from "../page-bridge/model.ts";
@@ -76,6 +77,11 @@ export type OneTalkPageBuyerFactsObservationPersister = (
sender: OneTalkServiceWorkerSender,
channelAccountId: string,
) => void | Promise<void>;
export type OneTalkPageRenderedCardObservationPersister = (
message: OneTalkPageRenderedCardObservedMessage,
sender: OneTalkServiceWorkerSender,
channelAccountId: string,
) => void | Promise<void>;
export type OneTalkPageIdentityHandler = (
sender: OneTalkServiceWorkerSender,
@@ -115,6 +121,7 @@ export type OneTalkServiceWorkerRuntimeOptions = {
persistPageObservation?: OneTalkPageObservationPersister;
persistPageProfileObservation?: OneTalkPageProfileObservationPersister;
persistPageBuyerFactsObservation?: OneTalkPageBuyerFactsObservationPersister;
persistPageRenderedCardObservation?: OneTalkPageRenderedCardObservationPersister;
onPageIdentity?: OneTalkPageIdentityHandler;
onRepeatedPageHello?: OneTalkRepeatedPageHelloHandler;
onPageDisconnect?: () => void;
@@ -390,6 +397,20 @@ const dispatchPageBuyerFactsObservation = (
});
};
const dispatchPageRenderedCardObservation = (
options: OneTalkServiceWorkerRuntimeOptions,
connection: PageConnection,
message: OneTalkPageRenderedCardObservedMessage,
): void => {
if (!connection.channelAccountId || connection.channelAccountId !== message.channelAccountId)
return;
const persist = options.persistPageRenderedCardObservation;
if (!persist) return;
void Promise.resolve(persist(message, connection.sender, connection.channelAccountId)).catch(
(error) => reportRuntimeError(options.onError, error),
);
};
const resolvePageCommandResult = (
connection: PageConnection,
message: OneTalkPageCommandResultMessage,
@@ -467,6 +488,9 @@ const createPageMessageHandler = (
case "onetalk.page.buyer-facts-observed":
dispatchPageBuyerFactsObservation(options, connection, message);
return;
case "onetalk.page.rendered-card-observed":
dispatchPageRenderedCardObservation(options, connection, message);
return;
case "onetalk.page.command-result":
resolvePageCommandResult(connection, message, options.onDiagnostic);
return;
@@ -1,6 +1,7 @@
// 持久化 OneTalk 同步账本与页面观察
import {
cloneOneTalkRenderedCardContent,
isOneTalkMessage,
type OneTalkContactProfile,
type OneTalkObservedMessage,
@@ -8,18 +9,21 @@ import {
type OneTalkSyncAnomalyCode,
type OneTalkSyncMode,
type OneTalkSyncResult,
type OneTalkRenderedCardContent,
type OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import { traceOneTalkObservedMessages } from "../diagnostics/message-trace.ts";
export const ONE_TALK_MESSAGE_DATABASE_NAME = "trade-message-center";
export const ONE_TALK_SYNC_DATABASE_VERSION = 8;
export const ONE_TALK_SYNC_DATABASE_VERSION = 9;
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 const ONE_TALK_CONVERSATION_BOOTSTRAP_STORE_NAME = "onetalk_conversation_bootstraps";
export const ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME = "onetalk_rendered_card_ledger";
export const ONE_TALK_CONVERSATION_BOOTSTRAP_MIGRATION_ID = "direct-discovery-before-history-v1";
export type OneTalkConversationBootstrapPhase =
@@ -130,6 +134,67 @@ export type OneTalkContactProfileLedgerRecord = {
lastUploadedAt?: number;
};
/** 独立于 base candidate 的 durable rendered-card ACK 账本。 */
export type OneTalkRenderedCardLedgerRecord = {
key: string;
channelAccountId: string;
conversationId: string;
messageId: string;
content: OneTalkRenderedCardContent;
contentFingerprint: string;
observedAtMs: number;
/** MAIN Fiber identity cross-check retained only for restart-safe candidate validation. */
baseDirection: "received" | "sent";
/** MAIN Fiber sendTime cross-check retained only for restart-safe candidate validation. */
baseSentAtMs: number;
status: "pending_ack" | "confirmed" | "rejected";
requestId?: string;
firstObservedAt: number;
updatedAt: number;
confirmedAt?: number;
rejectedAt?: number;
rejectionCode?: "base_message_missing" | "content_conflict" | "invalid_card";
lastConflictingFingerprint?: string;
};
export type OneTalkRenderedCardLedgerStore = {
observe: (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
baseEvidence: {
direction: "received" | "sent";
sentAtMs: number;
};
observedAt?: number;
}) => Promise<OneTalkRenderedCardLedgerRecord>;
get: (
channelAccountId: string,
conversationId: string,
messageId: string,
) => Promise<OneTalkRenderedCardLedgerRecord | null>;
listPending: (channelAccountId?: string) => Promise<OneTalkRenderedCardLedgerRecord[]>;
markAcknowledged: (input: {
channelAccountId: string;
conversationId: string;
messageId: string;
contentFingerprint: string;
observedAtMs: number;
requestId: string;
status: "accepted" | "duplicate" | "conflict" | "rejected";
rejectionCode?: "base_message_missing" | "content_conflict" | "invalid_card";
at?: number;
}) => Promise<boolean>;
markSent: (input: {
channelAccountId: string;
conversationId: string;
messageId: string;
contentFingerprint: string;
observedAtMs: number;
requestId: string;
sentAt?: number;
}) => Promise<OneTalkRenderedCardLedgerRecord | null>;
};
export type PersistObservedBatchInput = {
channelAccountId: string;
conversationId: string;
@@ -280,6 +345,7 @@ const ensureSyncStores = (database: IDBDatabase): void => {
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
ONE_TALK_CONVERSATION_BOOTSTRAP_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
];
for (const storeName of stores) {
if (!database.objectStoreNames.contains(storeName)) {
@@ -364,6 +430,210 @@ export const createOneTalkConversationBootstrapStore = (
return { get, putConfirmed: write, update: write };
};
/** 创建 rendered-card ledger;同 key 的替代内容永远不覆盖首个持久快照。 */
export const createOneTalkRenderedCardLedgerStore = (
factory: IDBFactory = indexedDB,
now: () => number = () => Date.now(),
): OneTalkRenderedCardLedgerStore => {
let databasePromise: Promise<IDBDatabase> | null = null;
const recordWrites = new Map<string, Promise<void>>();
const database = (): Promise<IDBDatabase> => {
databasePromise ??= openSyncDatabase(factory).catch((error: unknown) => {
databasePromise = null;
throw error;
});
return databasePromise;
};
const get = async (channelAccountId: string, conversationId: string, messageId: string) => {
const value = await readOne<OneTalkRenderedCardLedgerRecord>(
await database(),
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
candidateKey(channelAccountId, conversationId, messageId),
);
return value ?? null;
};
const write = async (value: OneTalkRenderedCardLedgerRecord): Promise<void> => {
const transaction = (await database()).transaction(
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
"readwrite",
);
const completion = transactionResult(transaction);
transaction.objectStore(ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME).put({
...value,
content: cloneOneTalkRenderedCardContent(value.content),
});
await completion;
};
const serializeRecordWrite = <T>(key: string, operation: () => Promise<T>): Promise<T> => {
const previous = recordWrites.get(key) ?? Promise.resolve();
const current = previous.catch(() => undefined).then(operation);
const settled = current.then(
() => undefined,
() => undefined,
);
recordWrites.set(key, settled);
void settled.finally(() => {
if (recordWrites.get(key) === settled) recordWrites.delete(key);
});
return current;
};
const observeOne = async (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
baseEvidence: { direction: "received" | "sent"; sentAtMs: number };
observedAt?: number;
}): Promise<OneTalkRenderedCardLedgerRecord> => {
const current = await get(
input.channelAccountId,
input.observation.conversationId,
input.observation.messageId,
);
const observedAt = input.observedAt ?? now();
if (current) {
const next =
current.contentFingerprint === input.observation.contentFingerprint
? {
...current,
updatedAt: observedAt,
}
: {
...current,
lastConflictingFingerprint: input.observation.contentFingerprint,
updatedAt: observedAt,
};
await write(next);
return next;
}
const record: OneTalkRenderedCardLedgerRecord = {
key: candidateKey(
input.channelAccountId,
input.observation.conversationId,
input.observation.messageId,
),
channelAccountId: input.channelAccountId,
conversationId: input.observation.conversationId,
messageId: input.observation.messageId,
content: cloneOneTalkRenderedCardContent(input.observation.content),
contentFingerprint: input.observation.contentFingerprint,
observedAtMs: input.observation.observedAtMs,
baseDirection: input.baseEvidence.direction,
baseSentAtMs: input.baseEvidence.sentAtMs,
status: "pending_ack",
firstObservedAt: observedAt,
updatedAt: observedAt,
};
await write(record);
return record;
};
const observe = (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
baseEvidence: { direction: "received" | "sent"; sentAtMs: number };
observedAt?: number;
}): Promise<OneTalkRenderedCardLedgerRecord> => {
const key = candidateKey(
input.channelAccountId,
input.observation.conversationId,
input.observation.messageId,
);
return serializeRecordWrite(key, () => observeOne(input));
};
const listPending = async (channelAccountId?: string) =>
(
await readAll<OneTalkRenderedCardLedgerRecord>(
await database(),
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
)
).filter(
(record) =>
record.status === "pending_ack" &&
(channelAccountId === undefined || record.channelAccountId === channelAccountId),
);
const markAcknowledged = async (input: {
channelAccountId: string;
conversationId: string;
messageId: string;
contentFingerprint: string;
observedAtMs: number;
requestId: string;
status: "accepted" | "duplicate" | "conflict" | "rejected";
rejectionCode?: "base_message_missing" | "content_conflict" | "invalid_card";
at?: number;
}): Promise<boolean> =>
serializeRecordWrite(
candidateKey(input.channelAccountId, input.conversationId, input.messageId),
async () => {
const record = await get(
input.channelAccountId,
input.conversationId,
input.messageId,
);
if (
!record ||
record.status !== "pending_ack" ||
record.contentFingerprint !== input.contentFingerprint ||
record.observedAtMs !== input.observedAtMs ||
record.requestId !== input.requestId
)
return false;
const terminalAt = input.at ?? now();
await write({
...record,
status:
input.status === "accepted" || input.status === "duplicate"
? "confirmed"
: "rejected",
updatedAt: terminalAt,
...(input.status === "accepted" || input.status === "duplicate"
? { confirmedAt: terminalAt }
: {
rejectedAt: terminalAt,
rejectionCode:
input.rejectionCode ??
(input.status === "conflict"
? "content_conflict"
: "invalid_card"),
}),
});
return true;
},
);
const markSent = async (input: {
channelAccountId: string;
conversationId: string;
messageId: string;
contentFingerprint: string;
observedAtMs: number;
requestId: string;
sentAt?: number;
}): Promise<OneTalkRenderedCardLedgerRecord | null> =>
serializeRecordWrite(
candidateKey(input.channelAccountId, input.conversationId, input.messageId),
async () => {
const record = await get(
input.channelAccountId,
input.conversationId,
input.messageId,
);
if (
!record ||
record.status !== "pending_ack" ||
record.contentFingerprint !== input.contentFingerprint ||
record.observedAtMs !== input.observedAtMs
)
return null;
const next = {
...record,
requestId: input.requestId,
updatedAt: input.sentAt ?? now(),
};
await write(next);
return next;
},
);
return { observe, get, listPending, markAcknowledged, markSent };
};
const transactionResult = (transaction: IDBTransaction): Promise<void> => {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
@@ -741,6 +1011,7 @@ export const createOneTalkSyncStore = (
ONE_TALK_CHECKPOINT_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
] as const;
const transaction = database.transaction([...storeNames], "readwrite");
const completion = transactionResult(transaction);
@@ -134,6 +134,7 @@ export const createOneTalkServiceWorkerSyncController = (
getActiveEngine: () => session?.getActive()?.engine ?? null,
getActiveProfileCoordinator: () => session?.getActive()?.profile ?? null,
getActiveBuyerFactCoordinator: () => session?.getActive()?.buyer ?? null,
getActiveRenderedCardCoordinator: () => session?.getActive()?.renderedCard ?? null,
getActiveChannelAccountId: () => session?.getConfig()?.channelAccountId ?? null,
getConfigurationEpoch: () => session?.getRevision() ?? 0,
onProfileDiagnostic: options.onProfileDiagnostic,
@@ -16,6 +16,11 @@ import {
} from "./sync-engine.ts";
import type { OneTalkConversationBootstrapStore, OneTalkSyncStore } from "./storage.ts";
import type { OneTalkContactProfileStore } from "./storage.ts";
import type { OneTalkRenderedCardLedgerStore } from "./storage.ts";
import {
createOneTalkRenderedCardCoordinator,
type OneTalkRenderedCardCoordinator,
} from "./rendered-card-coordinator.ts";
import type { OneTalkContactProfileDiagnostic } from "./contact-profile-coordinator.ts";
import {
createOneTalkContactProfileCoordinator,
@@ -31,6 +36,7 @@ export type OneTalkServiceWorkerSyncRuntimeOptions = Pick<
store: OneTalkSyncStore;
bootstrapStore: OneTalkConversationBootstrapStore;
profileStore?: OneTalkContactProfileStore;
renderedCardLedgerStore?: OneTalkRenderedCardLedgerStore;
onPageDiagnostic?: (event: OneTalkPageDiagnostic) => void;
onEngineDiagnostic?: (event: OneTalkSyncEngineDiagnostic) => void;
onProfileDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
@@ -40,6 +46,7 @@ export type OneTalkServiceWorkerSyncRuntime = {
runtime: OneTalkServiceWorkerRuntime;
engine: OneTalkSyncEngine;
profile?: OneTalkContactProfileCoordinator;
renderedCard?: OneTalkRenderedCardCoordinator;
dispose: () => void;
};
@@ -68,15 +75,29 @@ export const createOneTalkServiceWorkerSyncRuntime = (
onDiagnostic: options.onProfileDiagnostic,
})
: undefined;
const renderedCard = options.renderedCardLedgerStore
? createOneTalkRenderedCardCoordinator({
scope: options.scope,
ledger: options.renderedCardLedgerStore,
syncStore: options.store,
bright: options.bright,
...(options.createRequestId === undefined
? {}
: { createRequestId: options.createRequestId }),
onError: options.onError,
})
: undefined;
pageHost = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => profile ?? null,
getActiveRenderedCardCoordinator: () => renderedCard ?? null,
getActiveChannelAccountId: () => options.scope.channelAccountId,
onProfileDiagnostic: options.onProfileDiagnostic,
onError: options.onError ?? (() => undefined),
});
const unsubscribeProfileStatus = options.bright.subscribeStatus?.((state) => {
profile?.handleStatus(state);
renderedCard?.handleStatus(state);
if (profile) pageHost.handleProfileStatus(profile, state.status);
});
let requestSequence = 0;
@@ -99,6 +120,7 @@ export const createOneTalkServiceWorkerSyncRuntime = (
const router = createOneTalkServiceWorkerFrameRouter({
sync: engine,
...(profile === undefined ? {} : { profile }),
...(renderedCard === undefined ? {} : { renderedCard }),
send,
rebuild,
});
@@ -107,10 +129,12 @@ export const createOneTalkServiceWorkerSyncRuntime = (
runtime: pageHost.runtime,
engine,
...(profile === undefined ? {} : { profile }),
...(renderedCard === undefined ? {} : { renderedCard }),
dispose: () => {
unsubscribeRouter();
unsubscribeProfileStatus?.();
profile?.dispose();
renderedCard?.dispose();
engine.dispose();
options.bright.disconnect();
},
@@ -3,7 +3,10 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkBuyerFactFingerprint } from "@trade-message-center/onetalk-contract";
import {
createOneTalkBuyerFactFingerprint,
createOneTalkRenderedCardContentFingerprint,
} from "@trade-message-center/onetalk-contract";
import {
createOneTalkPageCommandMessage,
createOneTalkPageCommandResultMessage,
@@ -12,6 +15,7 @@ import {
createOneTalkPageObservedMessage,
createOneTalkPageProfileObservedMessage,
createOneTalkPageBuyerFactsObservedMessage,
createOneTalkPageRenderedCardObservedMessage,
decodeOneTalkPageConversationDiscoveryResult,
decodeOneTalkPageMessage,
isOneTalkMainToIsolatedMessage,
@@ -306,6 +310,62 @@ test("decodes one versioned JSON envelope and rejects malformed shapes", () => {
);
});
test("rejects rendered-card observations with forged identity or fingerprint fields", () => {
const content = {
version: 1,
kind: "rendered_order",
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const observation = {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 1_700_000_000_000,
};
const message = createOneTalkPageRenderedCardObservedMessage(
"account-1",
[observation],
[
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: "received",
sentAtMs: 1_699_999_999_999,
},
],
);
assert.deepEqual(decodeOneTalkPageMessage(message), message);
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [{ ...observation, extra: "unexpected" }],
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [{ ...observation, contentFingerprint: "forged" }],
}),
null,
);
assert.equal(
decodeOneTalkPageMessage({
...message,
observations: [{ ...observation, conversationId: "" }],
}),
null,
);
assert.equal(decodeOneTalkPageMessage({ ...message, baseEvidence: [] }), null);
});
test("deep-clones extended buyer facts at the page bridge boundary", () => {
const created = createOneTalkPageBuyerFactsObservedMessage(
[extendedBuyerFact],
@@ -0,0 +1,243 @@
// 验证渲染卡片 durable-first、基础确认门和精确 ACK
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkRenderedCardContentFingerprint } from "@trade-message-center/onetalk-contract";
import { createOneTalkRenderedCardCoordinator } from "../src/onetalk/service-worker/rendered-card-coordinator.ts";
const scope = { channelAccountId: "account-1", deviceId: "device-1" };
const content = {
version: 1,
kind: "rendered_order",
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const observation = {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 100,
};
const key = JSON.stringify([
scope.channelAccountId,
observation.conversationId,
observation.messageId,
]);
const baseEvidence = [
{
conversationId: observation.conversationId,
messageId: observation.messageId,
direction: "received",
sentAtMs: 99,
},
];
test("writes the card ledger before send, gates on base confirmation, resends after reconnect, and exact-matches ACK", async () => {
const timeline = [];
const records = new Map();
let baseStatus = "pending_ack";
const acknowledgements = [];
const ledger = {
observe: async ({ channelAccountId, observation: value, baseEvidence: evidence }) => {
timeline.push("write");
const record = {
key,
channelAccountId,
...value,
baseDirection: evidence.direction,
baseSentAtMs: evidence.sentAtMs,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
};
records.set(key, record);
return record;
},
listPending: async () =>
[...records.values()].filter((record) => record.status === "pending_ack"),
markSent: async (input) => {
const record = records.get(key);
if (
!record ||
input.contentFingerprint !== record.contentFingerprint ||
input.observedAtMs !== record.observedAtMs
)
return null;
record.requestId = input.requestId;
return record;
},
markAcknowledged: async (input) => {
acknowledgements.push(input);
const record = records.get(key);
if (
!record ||
input.contentFingerprint !== record.contentFingerprint ||
input.observedAtMs !== record.observedAtMs ||
input.requestId !== record.requestId
)
return false;
record.status =
input.status === "accepted" || input.status === "duplicate"
? "confirmed"
: "rejected";
return true;
},
};
const frames = [];
const bright = {
isOnline: () => true,
send: (frame) => {
timeline.push("send");
frames.push(frame);
return true;
},
};
const coordinator = createOneTalkRenderedCardCoordinator({
scope,
ledger,
syncStore: {
getCandidate: async () => ({
status: baseStatus,
message: { direction: "received", sentAtMs: 99 },
}),
},
bright,
createRequestId: () => "card-request",
});
await coordinator.observe([observation], baseEvidence);
assert.deepEqual(timeline, ["write"]);
baseStatus = "confirmed";
coordinator.handleStatus({ status: "authenticated", permissions: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(frames.length, 1);
assert.equal(frames[0].type, "rendered.card.observed");
assert.deepEqual(timeline, ["write", "send"]);
coordinator.handleStatus({ status: "offline", permissions: [] });
coordinator.handleStatus({ status: "authenticated", permissions: [] });
await new Promise((resolve) => setImmediate(resolve));
assert.equal(frames.length, 2);
coordinator.handleFrame({
...frames[0],
requestId: "stale-card-request",
type: "rendered.card.ack",
payload: { ...observation, status: "accepted" },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(records.get(key).status, "pending_ack");
coordinator.handleFrame({
...frames[1],
type: "rendered.card.ack",
payload: { ...observation, status: "accepted" },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(records.get(key).status, "confirmed");
assert.equal(acknowledgements.length, 1);
assert.equal(acknowledgements[0].requestId, frames[1].requestId);
coordinator.handleFrame({
...frames[1],
type: "rendered.card.ack",
payload: { ...observation, contentFingerprint: "wrong", status: "accepted" },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(acknowledgements.length, 1);
assert.equal(records.get(key).status, "confirmed");
});
test("does not send when MAIN base evidence differs from the confirmed candidate", async () => {
const writes = [];
const coordinator = createOneTalkRenderedCardCoordinator({
scope,
ledger: {
observe: async ({ channelAccountId, observation: value, baseEvidence: evidence }) => ({
key,
channelAccountId,
...value,
baseDirection: evidence.direction,
baseSentAtMs: evidence.sentAtMs,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
}),
listPending: async () => [
{
key,
channelAccountId: scope.channelAccountId,
...observation,
baseDirection: "received",
baseSentAtMs: 99,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
},
],
markSent: async () => assert.fail("must not persist a send request"),
markAcknowledged: async () => false,
},
syncStore: {
getCandidate: async () => ({
status: "confirmed",
message: { direction: "sent", sentAtMs: 99 },
}),
},
bright: { isOnline: () => true, send: (frame) => writes.push(frame) },
});
await coordinator.observe([observation], baseEvidence);
assert.deepEqual(writes, []);
});
test("recovers a pending record after worker restart using durable base cross-check metadata", async () => {
const records = new Map([
[
key,
{
key,
channelAccountId: scope.channelAccountId,
...observation,
baseDirection: "received",
baseSentAtMs: 99,
status: "pending_ack",
firstObservedAt: 1,
updatedAt: 1,
},
],
]);
const frames = [];
const coordinator = createOneTalkRenderedCardCoordinator({
scope,
ledger: {
observe: async () =>
assert.fail("recovery must not depend on a fresh page observation"),
listPending: async () => [...records.values()],
markSent: async (input) => {
const record = records.get(key);
record.requestId = input.requestId;
return record;
},
markAcknowledged: async () => false,
},
syncStore: {
getCandidate: async () => ({
status: "confirmed",
message: { direction: "received", sentAtMs: 99 },
}),
},
bright: { isOnline: () => true, send: (frame) => frames.push(frame) || true },
createRequestId: () => "recovered-request",
});
await coordinator.handlePageReady();
assert.equal(frames.length, 1);
assert.equal(frames[0].type, "rendered.card.observed");
});
@@ -0,0 +1,81 @@
// 验证渲染卡片 observer 在消息列表异步挂载时仍能建立监听。
import assert from "node:assert/strict";
import test from "node:test";
import { installOneTalkRenderedCardObserver } from "../src/onetalk/main-page/card-observer/entry.ts";
test("installs a mutation observer before the first message wrapper exists", () => {
const observers = [];
const listeners = new Map();
const page = {
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
document: {
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [];
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
installOneTalkRenderedCardObserver(page, () => undefined);
assert.equal(observers.length, 1);
assert.equal(observers[0].targets.length, 1);
assert.equal(listeners.has("pagehide"), true);
});
test("rebinds the scoped message observer when its list root is replaced", () => {
const observers = [];
const listeners = new Map();
const rootParent = { parentElement: null, querySelectorAll: () => [] };
const firstRoot = { parentElement: rootParent, querySelectorAll: () => [] };
const secondRoot = { parentElement: rootParent, querySelectorAll: () => [] };
let wrapper = { parentElement: firstRoot };
const page = {
location: { href: "https://onetalk.alibaba.com/message/default.htm" },
document: {
querySelectorAll: (selector) => {
assert.equal(selector, ".message-item-wrapper");
return [wrapper];
},
},
MutationObserver: class {
constructor(callback) {
this.callback = callback;
this.targets = [];
observers.push(this);
}
observe(target) {
this.targets.push(target);
}
disconnect() {}
},
addEventListener: (type, listener) => listeners.set(type, listener),
};
installOneTalkRenderedCardObserver(page, () => undefined);
assert.ok(observers[0].targets.includes(firstRoot));
wrapper = { parentElement: secondRoot };
observers[0].callback([]);
assert.ok(observers[0].targets.includes(secondRoot));
listeners.get("pagehide")();
});
@@ -3,6 +3,7 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkRenderedCardContentFingerprint } from "@trade-message-center/onetalk-contract";
import {
ONE_TALK_ANOMALY_STORE_NAME,
ONE_TALK_CANDIDATE_STORE_NAME,
@@ -10,6 +11,8 @@ import {
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
ONE_TALK_CONVERSATION_BOOTSTRAP_STORE_NAME,
ONE_TALK_MESSAGE_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
createOneTalkRenderedCardLedgerStore,
createOneTalkSyncStore,
createOneTalkConversationBootstrapStore,
} from "../src/onetalk/service-worker/storage.ts";
@@ -233,12 +236,13 @@ test("creates durable stores, keeps confirmed candidates, and merges anomalies",
ONE_TALK_CHECKPOINT_STORE_NAME,
"onetalk_contact_profiles",
ONE_TALK_CONVERSATION_BOOTSTRAP_STORE_NAME,
ONE_TALK_RENDERED_CARD_LEDGER_STORE_NAME,
].sort(),
);
});
test("preserves existing stores while adding the bootstrap marker store from v7", async () => {
for (const oldVersion of [7]) {
test("preserves existing stores while adding new stores from v7 and v8", async () => {
for (const oldVersion of [7, 8]) {
const factory = new FakeFactory(oldVersion);
const extensionStorage = new Map([
["onetalk.config", { channelAccountId: "account-1" }],
@@ -279,6 +283,30 @@ test("preserves existing stores while adding the bootstrap marker store from v7"
test("clears only one conversation history ledger after its transaction commits", async () => {
const factory = new FakeFactory();
const store = createOneTalkSyncStore(factory, () => 500);
const renderedCardStore = createOneTalkRenderedCardLedgerStore(factory, () => 500);
const renderedCardContent = {
version: 1,
kind: "rendered_order",
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
await renderedCardStore.observe({
channelAccountId: "account-1",
observation: {
conversationId: "conversation-1",
messageId: "message-1",
content: renderedCardContent,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(renderedCardContent),
observedAtMs: 500,
},
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 500,
});
await store.persistObservedBatch({
channelAccountId: "account-1",
conversationId: "conversation-1",
@@ -323,6 +351,7 @@ test("clears only one conversation history ledger after its transaction commits"
await store.clearConversationHistory("account-1", "conversation-1");
assert.equal(await renderedCardStore.get("account-1", "conversation-1", "message-1"), null);
assert.equal(await store.getCheckpoint("account-1", "conversation-1"), null);
assert.deepEqual(await store.listCandidates("account-1", "conversation-1"), []);
assert.deepEqual(await store.listAnomalies("account-1", "conversation-1"), []);
@@ -348,3 +377,101 @@ test("stores bootstrap markers by account and stable migration identifier", asyn
assert.equal(marker.confirmedBatchId, "batch-1");
assert.equal(await store.get("account-2", "direct-discovery-before-history-v1"), null);
});
test("preserves the first rendered-card snapshot across concurrent same-key observations", async () => {
const factory = new FakeFactory();
const store = createOneTalkRenderedCardLedgerStore(factory, () => 500);
const firstContent = {
version: 1,
kind: "rendered_order",
title: "First order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const secondContent = { ...firstContent, title: "Second order" };
const first = {
conversationId: "conversation-1",
messageId: "message-1",
content: firstContent,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(firstContent),
observedAtMs: 500,
};
const second = {
...first,
content: secondContent,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(secondContent),
observedAtMs: 501,
};
await Promise.all([
store.observe({
channelAccountId: "account-1",
observation: first,
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 500,
}),
store.observe({
channelAccountId: "account-1",
observation: second,
baseEvidence: { direction: "received", sentAtMs: 499 },
observedAt: 501,
}),
]);
const record = await store.get("account-1", "conversation-1", "message-1");
assert.deepEqual(record.content, firstContent);
assert.equal(record.lastConflictingFingerprint, second.contentFingerprint);
});
test("keeps the first pending ACK correlation while serializing same-key ledger writes", async () => {
const factory = new FakeFactory();
const store = createOneTalkRenderedCardLedgerStore(factory, () => 500);
const content = {
version: 1,
kind: "rendered_order",
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const first = {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 500,
};
const second = { ...first, observedAtMs: 501 };
const baseEvidence = { direction: "received", sentAtMs: 499 };
await store.observe({ channelAccountId: "account-1", observation: first, baseEvidence });
await store.markSent({
channelAccountId: "account-1",
conversationId: first.conversationId,
messageId: first.messageId,
contentFingerprint: first.contentFingerprint,
observedAtMs: first.observedAtMs,
requestId: "request-1",
});
await Promise.all([
store.observe({ channelAccountId: "account-1", observation: second, baseEvidence }),
store.markAcknowledged({
channelAccountId: "account-1",
conversationId: first.conversationId,
messageId: first.messageId,
contentFingerprint: first.contentFingerprint,
observedAtMs: first.observedAtMs,
requestId: "request-1",
status: "accepted",
}),
]);
const record = await store.get("account-1", first.conversationId, first.messageId);
assert.equal(record.observedAtMs, first.observedAtMs);
assert.equal(record.status, "confirmed");
});
@@ -0,0 +1,53 @@
CREATE TABLE "onetalk_rendered_card_content" (
"channel_account_id" text NOT NULL,
"conversation_id" text NOT NULL,
"message_id" text NOT NULL,
"rendered_card_content" jsonb NOT NULL,
"rendered_card_content_fingerprint" text NOT NULL,
"rendered_card_observed_at_ms" bigint NOT NULL,
"first_confirmed_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_observed_at" timestamp with time zone DEFAULT now() NOT NULL,
"conflict_count" integer DEFAULT 0 NOT NULL,
"last_conflicting_fingerprint" text,
"last_conflict_observed_at_ms" bigint,
CONSTRAINT "onetalk_rendered_card_content_channel_account_id_conversation_id_message_id_pk" PRIMARY KEY("channel_account_id","conversation_id","message_id"),
CONSTRAINT "onetalk_rendered_card_content_v1_chk" CHECK (jsonb_typeof("onetalk_rendered_card_content"."rendered_card_content") = 'object' and ("onetalk_rendered_card_content"."rendered_card_content" ->> 'version') = '1' and ("onetalk_rendered_card_content"."rendered_card_content" ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order'))
);
--> statement-breakpoint
CREATE INDEX "onetalk_rendered_card_content_scope_idx" ON "onetalk_rendered_card_content" USING btree ("channel_account_id","conversation_id","message_id");
--> statement-breakpoint
COMMENT ON TABLE "onetalk_rendered_card_content"
IS 'OneTalk React rendered-card supplement; first accepted content remains immutable.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."channel_account_id"
IS 'Base message account component of the exact composite identity.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."conversation_id"
IS 'Base message conversation component of the exact composite identity.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."message_id"
IS 'Base message ID component of the exact composite identity.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."rendered_card_content"
IS 'Validated allowlisted rendered-card snapshot; never raw Fiber props.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."rendered_card_content_fingerprint"
IS 'Canonical supplement fingerprint; full snapshot comparison remains authoritative.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."rendered_card_observed_at_ms"
IS 'Page render observation epoch milliseconds, distinct from base message sent time.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."first_confirmed_at"
IS 'Server time at first accepted supplement commit.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."last_observed_at"
IS 'Server time at latest duplicate observation of the accepted supplement.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."conflict_count"
IS 'Count of distinct later supplement snapshots; accepted content is never overwritten.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."last_conflicting_fingerprint"
IS 'Fingerprint of the latest conflicting supplement snapshot.';
--> statement-breakpoint
COMMENT ON COLUMN "onetalk_rendered_card_content"."last_conflict_observed_at_ms"
IS 'Page observation time of the latest conflicting supplement snapshot.';
+977
View File
@@ -0,0 +1,977 @@
{
"id": "4a1b7f8f-de1b-411e-939b-3e2907d39c09",
"prevId": "5bdeaafd-a264-4693-9a5d-72fcc245c5d3",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.onetalk_buyer_fact": {
"name": "onetalk_buyer_fact",
"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
},
"buyer_tags": {
"name": "buyer_tags",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"buyer_features": {
"name": "buyer_features",
"type": "text[]",
"primaryKey": false,
"notNull": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false
},
"registration_date": {
"name": "registration_date",
"type": "text",
"primaryKey": false,
"notNull": false
},
"company_website": {
"name": "company_website",
"type": "text",
"primaryKey": false,
"notNull": false
},
"tags_state": {
"name": "tags_state",
"type": "onetalk_buyer_fact_source_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"features_state": {
"name": "features_state",
"type": "onetalk_buyer_fact_source_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": true
},
"contact_details_state": {
"name": "contact_details_state",
"type": "onetalk_buyer_fact_source_state",
"typeSchema": "public",
"primaryKey": false,
"notNull": false
},
"tags_last_attempted_at": {
"name": "tags_last_attempted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"features_last_attempted_at": {
"name": "features_last_attempted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"contact_details_last_attempted_at": {
"name": "contact_details_last_attempted_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"tags_confirmed_at": {
"name": "tags_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"features_confirmed_at": {
"name": "features_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"contact_details_confirmed_at": {
"name": "contact_details_confirmed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"tags_error_code": {
"name": "tags_error_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"features_error_code": {
"name": "features_error_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"contact_details_error_code": {
"name": "contact_details_error_code",
"type": "text",
"primaryKey": false,
"notNull": false
},
"fact_fingerprint": {
"name": "fact_fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"received_at": {
"name": "received_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_buyer_fact_channel_account_id_conversation_id_pk": {
"name": "onetalk_buyer_fact_channel_account_id_conversation_id_pk",
"columns": ["channel_account_id", "conversation_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"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
},
"last_contact_time_ms": {
"name": "last_contact_time_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
},
"message_preview": {
"name": "message_preview",
"type": "text",
"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_generation": {
"name": "history_generation",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'initial'"
},
"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": {}
},
"onetalk_conversation_contact_time_idx": {
"name": "onetalk_conversation_contact_time_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "last_contact_time_ms",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"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', 'business_card', 'inquiry', 'order', 'product') and ((\"onetalk_message\".\"content\" ->> 'kind') <> 'business_card' or \"onetalk_message\".\"content\" = '{\"version\":1,\"kind\":\"business_card\"}'::jsonb)"
}
},
"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
},
"public.onetalk_rendered_card_content": {
"name": "onetalk_rendered_card_content",
"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
},
"rendered_card_content": {
"name": "rendered_card_content",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"rendered_card_content_fingerprint": {
"name": "rendered_card_content_fingerprint",
"type": "text",
"primaryKey": false,
"notNull": true
},
"rendered_card_observed_at_ms": {
"name": "rendered_card_observed_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": true
},
"first_confirmed_at": {
"name": "first_confirmed_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()"
},
"conflict_count": {
"name": "conflict_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"last_conflicting_fingerprint": {
"name": "last_conflicting_fingerprint",
"type": "text",
"primaryKey": false,
"notNull": false
},
"last_conflict_observed_at_ms": {
"name": "last_conflict_observed_at_ms",
"type": "bigint",
"primaryKey": false,
"notNull": false
}
},
"indexes": {
"onetalk_rendered_card_content_scope_idx": {
"name": "onetalk_rendered_card_content_scope_idx",
"columns": [
{
"expression": "channel_account_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "conversation_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "message_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"onetalk_rendered_card_content_channel_account_id_conversation_id_message_id_pk": {
"name": "onetalk_rendered_card_content_channel_account_id_conversation_id_message_id_pk",
"columns": ["channel_account_id", "conversation_id", "message_id"]
}
},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {
"onetalk_rendered_card_content_v1_chk": {
"name": "onetalk_rendered_card_content_v1_chk",
"value": "jsonb_typeof(\"onetalk_rendered_card_content\".\"rendered_card_content\") = 'object' and (\"onetalk_rendered_card_content\".\"rendered_card_content\" ->> 'version') = '1' and (\"onetalk_rendered_card_content\".\"rendered_card_content\" ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order')"
}
},
"isRLSEnabled": false
}
},
"enums": {
"public.onetalk_anomaly_status": {
"name": "onetalk_anomaly_status",
"schema": "public",
"values": ["open", "resolved", "ignored"]
},
"public.onetalk_buyer_fact_source_state": {
"name": "onetalk_buyer_fact_source_state",
"schema": "public",
"values": ["pending", "confirmed", "failed"]
},
"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
@@ -99,6 +99,13 @@
"when": 1789361108983,
"tag": "0013_nifty_thunderbird",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1789399393296,
"tag": "0014_youthful_iron_lad",
"breakpoints": true
}
]
}
+8
View File
@@ -25,6 +25,8 @@ import {
createOneTalkBuyerFactService,
createOneTalkRepository,
createOneTalkService,
createOneTalkRenderedCardRepository,
createOneTalkRenderedCardService,
} from "./onetalk/index.ts";
import { installWebsocket } from "./websocket/index.ts";
import type { OneTalkConnectionRegistry } from "./websocket/registry.ts";
@@ -34,6 +36,7 @@ import type {
OneTalkProfileService,
OneTalkReadService,
OneTalkService,
OneTalkRenderedCardService,
} from "./onetalk/index.ts";
import type { OneTalkDiagnosticsSink } from "./websocket/diagnostics.ts";
import { createOneTalkCutoverPolicy, type OneTalkCutoverPolicy } from "./cutover-policy.ts";
@@ -44,6 +47,7 @@ export type AppDependencies = {
authorization?: OneTalkAuthorizationReader;
oneTalkService?: OneTalkService;
profileService?: OneTalkProfileService;
renderedCardService?: OneTalkRenderedCardService;
buyerFactService?: OneTalkBuyerFactService;
readService?: OneTalkReadService;
oneTalkRegistry?: OneTalkConnectionRegistry;
@@ -93,6 +97,9 @@ export const createApp = (
const profileService =
dependencies.profileService ??
createOneTalkProfileService(createOneTalkProfileRepository(database.db));
const renderedCardService =
dependencies.renderedCardService ??
createOneTalkRenderedCardService(createOneTalkRenderedCardRepository(database.db));
const buyerFactService =
dependencies.buyerFactService ??
createOneTalkBuyerFactService(createOneTalkBuyerFactRepository(database.db));
@@ -111,6 +118,7 @@ export const createApp = (
authorization,
service: oneTalkService,
profileService,
renderedCardService,
buyerFactService,
readService,
registry: dependencies.oneTalkRegistry,
+56 -4
View File
@@ -17,11 +17,14 @@ import {
uuid,
uniqueIndex,
} from "drizzle-orm/pg-core";
import {
ONETALK_INITIAL_HISTORY_GENERATION,
type OneTalkMessageContent,
import type {
OneTalkMessageContent,
OneTalkRenderedCardContent,
} from "@trade-message-center/onetalk-contract";
/** Drizzle CJS generation cannot load contract runtime exports; this literal is drift-tested. */
export const ONETALK_SCHEMA_INITIAL_HISTORY_GENERATION = "initial" as const;
export type JsonValue =
| null
| boolean
@@ -121,6 +124,55 @@ export const onetalkMessage = pgTable(
],
);
/** 与基础消息分离的只读 React 渲染卡片补全事实。 */
export const onetalkRenderedCardContent = pgTable(
"onetalk_rendered_card_content",
{
/** 与基础消息完全一致的账号复合身份。 */
channelAccountId: text("channel_account_id").notNull(),
/** 与基础消息完全一致的会话复合身份。 */
conversationId: text("conversation_id").notNull(),
/** 与基础消息完全一致的消息复合身份。 */
messageId: text("message_id").notNull(),
/** 经 shared contract 验证的受限渲染卡片快照。 */
renderedCardContent: jsonb("rendered_card_content")
.$type<OneTalkRenderedCardContent>()
.notNull(),
/** 受限快照的 contract 指纹;不替代完整快照比较。 */
renderedCardContentFingerprint: text("rendered_card_content_fingerprint").notNull(),
/** 页面实际渲染卡片的 epoch milliseconds,不是消息发送时间。 */
renderedCardObservedAtMs: bigint("rendered_card_observed_at_ms", {
mode: "number",
}).notNull(),
/** 首次接受该补全事实的服务端审计时间。 */
firstConfirmedAt: timestamp("first_confirmed_at", { withTimezone: true })
.defaultNow()
.notNull(),
/** 最近一次收到同一补全事实的服务端审计时间。 */
lastObservedAt: timestamp("last_observed_at", { withTimezone: true })
.defaultNow()
.notNull(),
/** 不同内容尝试的次数;首个接受快照不可覆盖。 */
conflictCount: integer("conflict_count").notNull().default(0),
/** 最近一次冲突补全的指纹,仅用于诊断。 */
lastConflictingFingerprint: text("last_conflicting_fingerprint"),
/** 最近一次冲突 observation 的页面时间。 */
lastConflictObservedAtMs: bigint("last_conflict_observed_at_ms", { mode: "number" }),
},
(table) => [
primaryKey({ columns: [table.channelAccountId, table.conversationId, table.messageId] }),
index("onetalk_rendered_card_content_scope_idx").on(
table.channelAccountId,
table.conversationId,
table.messageId,
),
check(
"onetalk_rendered_card_content_v1_chk",
sql`jsonb_typeof(${table.renderedCardContent}) = 'object' and (${table.renderedCardContent} ->> 'version') = '1' and (${table.renderedCardContent} ->> 'kind') in ('rendered_inquiry', 'rendered_product', 'rendered_order')`,
),
],
);
/** 由插件枚举出的技术会话及其共享同步锚点;不按设备复制。 */
export const onetalkConversation = pgTable(
"onetalk_conversation",
@@ -156,7 +208,7 @@ export const onetalkConversation = pgTable(
/** 服务端持久化的单会话历史代际;所有消息和同步回执必须精确匹配。 */
historyGeneration: text("history_generation")
.notNull()
.default(ONETALK_INITIAL_HISTORY_GENERATION),
.default(ONETALK_SCHEMA_INITIAL_HISTORY_GENERATION),
/** 页面历史是否结束且有效消息已全部确认。 */
historyComplete: boolean("history_complete").notNull().default(false),
/** 该技术会话已提交的消息事实数量。 */
+7
View File
@@ -6,6 +6,8 @@ export { createOneTalkProfileRepository } from "./profile-repository.ts";
export { createOneTalkProfileService } from "./profile-service.ts";
export { createOneTalkBuyerFactRepository } from "./buyer-fact-repository.ts";
export { createOneTalkBuyerFactService } from "./buyer-fact-service.ts";
export { createOneTalkRenderedCardRepository } from "./rendered-card-repository.ts";
export { createOneTalkRenderedCardService } from "./rendered-card-service.ts";
export { createOneTalkReadRepository } from "./read-repository.ts";
export { createOneTalkReadService, normalizeOneTalkReadQuery } from "./read-service.ts";
export {
@@ -77,3 +79,8 @@ export type {
OneTalkReadServiceDependencies,
} from "./read-model.ts";
export { ONETALK_SUMMARY_READ_PURPOSE } from "./read-model.ts";
export type {
OneTalkRenderedCardRepository,
OneTalkRenderedCardService,
OneTalkRenderedCardStoreResult,
} from "./rendered-card-model.ts";
+3
View File
@@ -4,6 +4,7 @@ import type {
CenterConversation,
OneTalkCenterMessage,
OneTalkMessageContent,
OneTalkRenderedCardContent,
OneTalkMindScope,
OneTalkSyncResult,
} from "@trade-message-center/onetalk-contract";
@@ -68,6 +69,8 @@ export type OneTalkReadMessageRow = {
direction: "received" | "sent";
sentAtMs: number;
content: OneTalkMessageContent;
/** 独立补全事实仅覆盖读取投影,绝不改写基础 content。 */
renderedCardContent?: OneTalkRenderedCardContent;
participantIds: string[];
/** Server-private raw fact; never projected to public CenterMessage. */
readStatus: number;
+14 -12
View File
@@ -1,10 +1,11 @@
// 投影已验证的 OneTalk 中心读取响应
import {
isOneTalkMessageContent,
isOneTalkCenterMessageContent,
type OneTalkHttpConversation,
type OneTalkCenterMessage,
type OneTalkMessage,
type OneTalkCenterMessageContent,
} from "@trade-message-center/onetalk-contract";
import type {
@@ -84,17 +85,14 @@ export const toHttpConversation = (
type OneTalkCenterMessageInput = Pick<
OneTalkMessage,
| "messageId"
| "conversationId"
| "senderId"
| "participantIds"
| "direction"
| "sentAtMs"
| "content"
"messageId" | "conversationId" | "senderId" | "participantIds" | "direction" | "sentAtMs"
>;
type OneTalkCenterMessageInputWithEffectiveContent = Omit<OneTalkCenterMessageInput, "content"> & {
content: OneTalkCenterMessageContent;
};
const projectMessageContent = (
content: OneTalkMessage["content"],
content: OneTalkCenterMessageContent,
customerProfile?: Pick<
OneTalkReadCustomerProfile,
"name" | "avatarUrl" | "countryCode" | "companyName"
@@ -112,13 +110,13 @@ const projectMessageContent = (
/** 从 normalized JSONB 事实和会话客户资料投影 Mind-facing 消息。 */
export const toOneTalkCenterMessage = (
message: OneTalkCenterMessageInput,
message: OneTalkCenterMessageInputWithEffectiveContent,
customerProfile?: Pick<
OneTalkReadCustomerProfile,
"name" | "avatarUrl" | "countryCode" | "companyName"
>,
): OneTalkCenterMessage => {
if (!isOneTalkMessageContent(message.content)) {
if (!isOneTalkCenterMessageContent(message.content)) {
throw new Error("Invalid persisted OneTalk message content");
}
return {
@@ -138,4 +136,8 @@ export const projectCenterMessage = (
OneTalkReadCustomerProfile,
"name" | "avatarUrl" | "countryCode" | "companyName"
>,
): OneTalkCenterMessage => toOneTalkCenterMessage(row, customerProfile);
): OneTalkCenterMessage =>
toOneTalkCenterMessage(
{ ...row, content: row.renderedCardContent ?? row.content },
customerProfile,
);
+50 -1
View File
@@ -8,6 +8,7 @@ import {
onetalkContactProfile,
onetalkConversation,
onetalkMessage,
onetalkRenderedCardContent,
} from "../database/schema/onetalk.ts";
import { OneTalkDatabaseError } from "./model.ts";
import type {
@@ -21,6 +22,12 @@ import type {
type MessageRow = typeof onetalkMessage.$inferSelect;
const messageKeyFor = (
channelAccountId: string,
conversationId: string,
messageId: string,
): string => JSON.stringify([channelAccountId, conversationId, messageId]);
type ConversationRow = Omit<
OneTalkReadConversationRow,
| "name"
@@ -251,6 +258,48 @@ const toReadMessage = (row: MessageRow): OneTalkReadMessageRow => {
};
};
/** 以完整复合键读取第二张补全表,保持基础消息排序与 cursor 完全不变。 */
const enrichMessages = async (
database: Database,
rows: OneTalkReadMessageRow[],
): Promise<OneTalkReadMessageRow[]> => {
if (rows.length === 0) return rows;
const channelAccountId = rows[0]!.channelAccountId;
const conversationId = rows[0]!.conversationId;
const messageIds = [...new Set(rows.map((row) => row.messageId))];
const enrichments = await database
.select({
channelAccountId: onetalkRenderedCardContent.channelAccountId,
conversationId: onetalkRenderedCardContent.conversationId,
messageId: onetalkRenderedCardContent.messageId,
content: onetalkRenderedCardContent.renderedCardContent,
})
.from(onetalkRenderedCardContent)
.where(
and(
eq(onetalkRenderedCardContent.channelAccountId, channelAccountId),
eq(onetalkRenderedCardContent.conversationId, conversationId),
inArray(onetalkRenderedCardContent.messageId, messageIds),
),
);
const byKey = new Map<string, (typeof enrichments)[number]>();
for (const enrichment of enrichments) {
const key = messageKeyFor(
enrichment.channelAccountId,
enrichment.conversationId,
enrichment.messageId,
);
if (byKey.has(key)) throw new Error("Duplicate OneTalk rendered-card read key");
byKey.set(key, enrichment);
}
return rows.map((row) => {
const enrichment = byKey.get(
messageKeyFor(row.channelAccountId, row.conversationId, row.messageId),
);
return enrichment ? { ...row, renderedCardContent: enrichment.content } : row;
});
};
const listConversations = async (
database: Database,
query: OneTalkReadListQuery,
@@ -380,7 +429,7 @@ const listMessages = async (
.where(historyWindowConditionFor(query))
.orderBy(desc(onetalkMessage.sentAtMs), desc(onetalkMessage.messageId))
.limit(query.limit + 1);
return rows.map(toReadMessage);
return enrichMessages(database, rows.map(toReadMessage));
};
/** 创建 OneTalk 只读 snapshot repository。 */
@@ -0,0 +1,31 @@
// 定义 OneTalk 渲染卡片补全的服务端领域边界
import type {
OneTalkCenterMessage,
OneTalkRenderedCardContent,
OneTalkRenderedCardObservation,
} from "@trade-message-center/onetalk-contract";
import type { OneTalkCommitGuard } from "./model.ts";
export type OneTalkRenderedCardStoreResult =
| { status: "accepted"; content: OneTalkRenderedCardContent; message: OneTalkCenterMessage }
| { status: "duplicate"; content: OneTalkRenderedCardContent; message: OneTalkCenterMessage }
| { status: "conflict"; content: OneTalkRenderedCardContent; message: OneTalkCenterMessage }
| { status: "rejected"; reason: "base_message_missing" };
export type OneTalkRenderedCardRepository = {
store: (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
receivedAt: Date;
commitGuard?: OneTalkCommitGuard;
}) => Promise<OneTalkRenderedCardStoreResult>;
};
export type OneTalkRenderedCardService = {
ingest: (input: {
channelAccountId: string;
observation: OneTalkRenderedCardObservation;
commitGuard?: OneTalkCommitGuard;
}) => Promise<OneTalkRenderedCardStoreResult>;
};
@@ -0,0 +1,133 @@
// 持久化不可覆盖的 OneTalk 渲染卡片补全事实
import { and, eq, sql } from "drizzle-orm";
import {
isSameOneTalkRenderedCardContent,
type OneTalkCenterMessage,
type OneTalkRenderedCardContent,
} from "@trade-message-center/onetalk-contract";
import type { Database } from "../database/index.ts";
import { onetalkMessage, onetalkRenderedCardContent } from "../database/schema/onetalk.ts";
import { OneTalkDatabaseError } from "./model.ts";
import type {
OneTalkRenderedCardRepository,
OneTalkRenderedCardStoreResult,
} from "./rendered-card-model.ts";
const keyCondition = (input: {
channelAccountId: string;
observation: { conversationId: string; messageId: string };
}) =>
and(
eq(onetalkRenderedCardContent.channelAccountId, input.channelAccountId),
eq(onetalkRenderedCardContent.conversationId, input.observation.conversationId),
eq(onetalkRenderedCardContent.messageId, input.observation.messageId),
);
const baseMessageCondition = (input: {
channelAccountId: string;
observation: { conversationId: string; messageId: string };
}) =>
and(
eq(onetalkMessage.channelAccountId, input.channelAccountId),
eq(onetalkMessage.conversationId, input.observation.conversationId),
eq(onetalkMessage.messageId, input.observation.messageId),
);
const effectiveMessageFor = (
row: typeof onetalkMessage.$inferSelect,
content: OneTalkRenderedCardContent,
): OneTalkCenterMessage => ({
messageId: row.messageId,
conversationId: row.conversationId,
senderId: row.senderId,
participantIds: [...row.participantIds],
direction: row.direction,
sentAtMs: row.sentAtMs,
content,
});
/** 在独立事务中写入首个补全快照,重复只更新时间,冲突只记元数据。 */
const store = async (
database: Database,
input: Parameters<OneTalkRenderedCardRepository["store"]>[0],
): Promise<OneTalkRenderedCardStoreResult> =>
database.transaction(async (transaction) => {
input.commitGuard?.assertValid();
const base = await transaction
.select()
.from(onetalkMessage)
.where(baseMessageCondition(input))
.limit(1)
.for("update");
input.commitGuard?.assertValid();
const baseMessage = base[0];
if (!baseMessage) return { status: "rejected", reason: "base_message_missing" };
const existing = await transaction
.select()
.from(onetalkRenderedCardContent)
.where(keyCondition(input))
.limit(1)
.for("update");
input.commitGuard?.assertValid();
const record = existing[0];
if (!record) {
await transaction.insert(onetalkRenderedCardContent).values({
channelAccountId: input.channelAccountId,
conversationId: input.observation.conversationId,
messageId: input.observation.messageId,
renderedCardContent: input.observation.content,
renderedCardContentFingerprint: input.observation.contentFingerprint,
renderedCardObservedAtMs: input.observation.observedAtMs,
firstConfirmedAt: input.receivedAt,
lastObservedAt: input.receivedAt,
});
input.commitGuard?.assertValid();
return {
status: "accepted",
content: input.observation.content,
message: effectiveMessageFor(baseMessage, input.observation.content),
};
}
const saved = record.renderedCardContent as OneTalkRenderedCardContent;
if (isSameOneTalkRenderedCardContent(saved, input.observation.content)) {
await transaction
.update(onetalkRenderedCardContent)
.set({ lastObservedAt: input.receivedAt })
.where(keyCondition(input));
input.commitGuard?.assertValid();
return {
status: "duplicate",
content: saved,
message: effectiveMessageFor(baseMessage, saved),
};
}
await transaction
.update(onetalkRenderedCardContent)
.set({
conflictCount: sql`${onetalkRenderedCardContent.conflictCount} + 1`,
lastConflictingFingerprint: input.observation.contentFingerprint,
lastConflictObservedAtMs: input.observation.observedAtMs,
})
.where(keyCondition(input));
input.commitGuard?.assertValid();
return {
status: "conflict",
content: saved,
message: effectiveMessageFor(baseMessage, saved),
};
});
/** 创建只操作 supplement 表、绝不委托基础消息 observe 的 repository。 */
export const createOneTalkRenderedCardRepository = (
database: Database,
): OneTalkRenderedCardRepository => ({
store: async (input) => {
try {
return await store(database, input);
} catch (error: unknown) {
if (error instanceof OneTalkDatabaseError) throw error;
if (error instanceof Error && error.message === "connection_commit_invalid")
throw error;
throw new OneTalkDatabaseError(error);
}
},
});
@@ -0,0 +1,30 @@
// 编排 OneTalk 渲染卡片补全的受控写入
import {
cloneOneTalkRenderedCardContent,
createOneTalkRenderedCardContentFingerprint,
} from "@trade-message-center/onetalk-contract";
import type {
OneTalkRenderedCardRepository,
OneTalkRenderedCardService,
} from "./rendered-card-model.ts";
/** 创建仅提交独立 supplement 事实的服务。 */
export const createOneTalkRenderedCardService = (
repository: OneTalkRenderedCardRepository,
now: () => Date = () => new Date(),
): OneTalkRenderedCardService => ({
ingest: async (input) => {
const content = cloneOneTalkRenderedCardContent(input.observation.content);
return repository.store({
channelAccountId: input.channelAccountId,
observation: {
...input.observation,
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
},
receivedAt: now(),
commitGuard: input.commitGuard,
});
},
});
+10
View File
@@ -15,6 +15,7 @@ import {
onetalkConversation,
onetalkMessage,
onetalkMessageAnomaly,
onetalkRenderedCardContent,
type JsonValue,
} from "../database/schema/onetalk.ts";
import {
@@ -790,6 +791,15 @@ const resetConversationHistory = async (
),
);
guard.assertValid();
await transaction
.delete(onetalkRenderedCardContent)
.where(
and(
eq(onetalkRenderedCardContent.channelAccountId, context.channelAccountId),
eq(onetalkRenderedCardContent.conversationId, conversationId),
),
);
guard.assertValid();
await transaction
.delete(onetalkMessageAnomaly)
.where(
@@ -99,6 +99,12 @@ export const ONETALK_AUTHENTICATED_ROUTE_DEFINITIONS = [
authorization: "session",
operation: "sync",
},
{
type: "rendered.card.observed",
connectionType: "plugin",
authorization: "session",
operation: "sync",
},
{
type: "storage.delete.ack",
connectionType: "plugin",
+5
View File
@@ -16,6 +16,7 @@ import type { OneTalkPublishFailureSink } from "./mind/publisher.ts";
import type {
OneTalkBuyerFactService,
OneTalkProfileService,
OneTalkRenderedCardService,
OneTalkReadService,
OneTalkService,
} from "../onetalk/index.ts";
@@ -38,6 +39,7 @@ export type WebsocketOptions = {
authorization?: OneTalkAuthorizationReader;
service: OneTalkService;
profileService: OneTalkProfileService;
renderedCardService?: OneTalkRenderedCardService;
buyerFactService?: OneTalkBuyerFactService;
readService?: OneTalkReadService;
registry?: OneTalkConnectionRegistry;
@@ -54,6 +56,7 @@ const registerWebsocketRoutes = (
authorization: OneTalkAuthorizationReader,
service: OneTalkService,
profileService: OneTalkProfileService,
renderedCardService: OneTalkRenderedCardService | undefined,
buyerFactService: OneTalkBuyerFactService | undefined,
readService: OneTalkReadService | undefined,
registry: OneTalkConnectionRegistry,
@@ -111,6 +114,7 @@ const registerWebsocketRoutes = (
authorization,
service,
profileService,
renderedCardService,
buyerFactService,
readService,
registry,
@@ -180,6 +184,7 @@ export const installWebsocket = (
authorization,
options.service,
options.profileService,
options.renderedCardService,
options.buyerFactService,
options.readService,
registry,
+47 -3
View File
@@ -5,6 +5,7 @@ import {
createOneTalkErrorFrame,
createOneTalkConversationUpdatedFrame,
createOneTalkMessageCreatedFrame,
createOneTalkMessageUpdatedFrame,
createOneTalkPluginStatusFrame,
createOneTalkSyncStatusFrame,
isSameOneTalkScope,
@@ -31,7 +32,12 @@ export type OneTalkPublishFailure = {
messageId?: string;
conversationId?: string;
scope: OneTalkMindScope;
eventType?: "message.created" | "plugin.status" | "sync.status" | "conversation.updated";
eventType?:
| "message.created"
| "message.updated"
| "plugin.status"
| "sync.status"
| "conversation.updated";
cause?: unknown;
};
@@ -44,6 +50,12 @@ export type OneTalkMindPublisher = {
scope: OneTalkMindScope;
policyEpoch?: number;
}) => Promise<void>;
publishMessageUpdated: (input: {
message: OneTalkCenterMessage;
requestId: string;
scope: OneTalkMindScope;
policyEpoch?: number;
}) => Promise<void>;
publishSyncStatus: (input: {
requestId: string;
scope: OneTalkMindScope;
@@ -101,7 +113,12 @@ export const createOneTalkMindPublisher = (options: {
const publishToConnections = async (input: {
candidates: OneTalkRegisteredConnection[];
requestId: string;
eventType: "plugin.status" | "sync.status" | "message.created" | "conversation.updated";
eventType:
| "plugin.status"
| "sync.status"
| "message.created"
| "message.updated"
| "conversation.updated";
message?: OneTalkCenterMessage;
policyEpoch?: number;
buildFrame: (connection: OneTalkRegisteredConnection) => OneTalkFrame;
@@ -273,6 +290,28 @@ export const createOneTalkMindPublisher = (options: {
);
};
const publishMessageUpdated = async (input: {
message: OneTalkCenterMessage;
requestId: string;
scope: OneTalkMindScope;
policyEpoch?: number;
}): Promise<void> => {
await enqueue(input.scope.channelAccountId, () =>
publishToConnections({
candidates: mindCandidatesForScope(input.scope),
requestId: input.requestId,
eventType: "message.updated",
message: input.message,
policyEpoch: input.policyEpoch,
buildFrame: (connection) =>
createOneTalkMessageUpdatedFrame(
frameContextFor(connection, input.requestId),
input.message,
),
}),
);
};
const publishSyncStatus = async (input: {
requestId: string;
scope: OneTalkMindScope;
@@ -323,5 +362,10 @@ export const createOneTalkMindPublisher = (options: {
},
});
return { publishMessageCreated, publishSyncStatus, publishConversationUpdated };
return {
publishMessageCreated,
publishMessageUpdated,
publishSyncStatus,
publishConversationUpdated,
};
};
@@ -0,0 +1,108 @@
// 编排渲染卡片补全的 commit、ACK 与实时更新顺序
import type { WebSocket } from "@fastify/websocket";
import {
createOneTalkRenderedCardAckFrame,
type OneTalkErrorCode,
type OneTalkRenderedCardObservedFrame,
} from "@trade-message-center/onetalk-contract";
import type { OneTalkRenderedCardService, OneTalkSourceContext } from "../../../onetalk/index.ts";
import type { OneTalkAuthenticatedClientFrame } from "../../authenticated-router.ts";
import type { OneTalkConnectionRegistry, OneTalkRegisteredConnection } from "../../registry.ts";
import type { OneTalkCommitGuard } from "../../../onetalk/index.ts";
type RenderedCardFrame = Extract<
OneTalkAuthenticatedClientFrame,
{ type: "rendered.card.observed" }
>;
type Request = {
socket: WebSocket;
frame: RenderedCardFrame;
context: OneTalkSourceContext;
guard: OneTalkCommitGuard;
policyEpoch: number;
canonical: OneTalkRegisteredConnection;
};
export type OneTalkRenderedCardFlow = { handle: (request: Request) => Promise<void> };
const scopeFor = (context: OneTalkSourceContext) => ({
mindUserId: context.mindUserId,
workspaceId: context.workspaceId,
channelAccountId: context.channelAccountId,
});
const ackPayloadFor = (
frame: OneTalkRenderedCardObservedFrame,
status: "accepted" | "duplicate" | "conflict" | "rejected",
rejectionCode?: "base_message_missing" | "content_conflict" | "invalid_card",
) => ({
conversationId: frame.payload.conversationId,
messageId: frame.payload.messageId,
contentFingerprint: frame.payload.contentFingerprint,
observedAtMs: frame.payload.observedAtMs,
status,
...(rejectionCode === undefined ? {} : { rejectionCode }),
});
/** 创建唯一拥有 rendered-card commit -> ACK -> publish 的 Flow。 */
export const createOneTalkRenderedCardFlow = (options: {
service: OneTalkRenderedCardService;
registry: OneTalkConnectionRegistry;
sendFrame: (
frame: RenderedCardFrame | ReturnType<typeof createOneTalkRenderedCardAckFrame>,
) => boolean;
isPolicyCurrent: (policyEpoch: number) => boolean;
isCommitGuardFailure: (error: unknown) => boolean;
closeForPause: () => void;
closeForDatabaseFailure: (frame: RenderedCardFrame) => void;
closeForAcknowledgementFailure: () => void;
reauthorize: (
frame: RenderedCardFrame,
) => Promise<{ ok: true } | { ok: false; code: OneTalkErrorCode }>;
handleAuthorizationFailure: (frame: RenderedCardFrame, code: OneTalkErrorCode) => void;
}): OneTalkRenderedCardFlow => ({
handle: async ({ canonical, context, frame, guard, policyEpoch, socket }) => {
let result;
try {
result = await options.service.ingest({
channelAccountId: context.channelAccountId,
observation: frame.payload,
commitGuard: guard,
});
} catch (error: unknown) {
if (!options.isPolicyCurrent(policyEpoch)) return options.closeForPause();
if (options.isCommitGuardFailure(error)) return;
return options.closeForDatabaseFailure(frame);
}
if (!options.isPolicyCurrent(policyEpoch)) return options.closeForPause();
const authorization = await options.reauthorize(frame);
if (!options.isPolicyCurrent(policyEpoch)) return options.closeForPause();
try {
guard.assertValid();
} catch (error: unknown) {
if (options.isCommitGuardFailure(error)) return;
throw error;
}
if (options.registry.getCanonicalConnection(socket) !== canonical) return;
if (!authorization.ok) return options.handleAuthorizationFailure(frame, authorization.code);
const ack =
result.status === "rejected"
? ackPayloadFor(frame, "rejected", result.reason)
: ackPayloadFor(
frame,
result.status,
result.status === "conflict" ? "content_conflict" : undefined,
);
if (!options.sendFrame(createOneTalkRenderedCardAckFrame(frame, ack)))
return options.closeForAcknowledgementFailure();
if (result.status !== "accepted") return;
guard.assertValid();
await options.registry.publishMessageUpdated({
message: result.message,
requestId: frame.requestId,
scope: scopeFor(context),
policyEpoch,
});
guard.assertValid();
},
});
+55 -1
View File
@@ -16,6 +16,7 @@ import {
OneTalkDatabaseError,
type OneTalkBuyerFactService,
type OneTalkProfileService,
type OneTalkRenderedCardService,
type OneTalkReadService,
type OneTalkService,
type OneTalkSourceContext,
@@ -35,6 +36,7 @@ import type {
OneTalkAuthenticatedRouter,
} from "../authenticated-router.ts";
import { createOneTalkProfileFlow } from "./flows/profile-flow.ts";
import { createOneTalkRenderedCardFlow } from "./flows/rendered-card-flow.ts";
import { createOneTalkSendConfirmationFlow } from "./flows/send-confirmation-flow.ts";
import { createOneTalkSyncFlows } from "./flows/sync-flows.ts";
import type { OneTalkRegisteredConnection } from "../registry.ts";
@@ -48,6 +50,7 @@ export type OneTalkPluginWebSocketHandlerOptions = Omit<
> & {
service: OneTalkService;
profileService: OneTalkProfileService;
renderedCardService?: OneTalkRenderedCardService;
buyerFactService?: OneTalkBuyerFactService;
readService?: OneTalkReadService;
authenticatedRouter: OneTalkAuthenticatedRouter;
@@ -88,7 +91,10 @@ const publishConversationUpdated = async (
};
const reauthorize = async (
context: OneTalkWebSocketEndpointContext,
frame: Extract<OneTalkFrame, { type: "contact.profile.observed" | "buyer.facts.observed" }>,
frame: Extract<
OneTalkFrame,
{ type: "contact.profile.observed" | "buyer.facts.observed" | "rendered.card.observed" }
>,
): Promise<{ ok: true } | { ok: false; code: OneTalkErrorCode }> => {
if (!isOneTalkPluginScope(frame.scope))
return { ok: false, code: ONETALK_ERROR_CODES.authorizationRejected };
@@ -187,6 +193,26 @@ export const createOneTalkPluginWebSocketHandler = (
input.policyEpoch,
),
});
const renderedCardFlow = options.renderedCardService
? createOneTalkRenderedCardFlow({
service: options.renderedCardService,
registry: context.options.registry,
sendFrame: context.sendFrame,
isPolicyCurrent: context.isPolicyCurrent,
isCommitGuardFailure,
closeForPause: context.closeForPause,
closeForDatabaseFailure: context.closeForDatabaseFailure,
closeForAcknowledgementFailure: () =>
context.close(CLOSE_INTERNAL_ERROR, "handler_failed"),
reauthorize: (frame) => reauthorize(context, frame),
handleAuthorizationFailure: (frame, code) => {
context.reportDecision(frame, code);
state.unregister?.();
context.sendError(frame, code);
context.close(CLOSE_POLICY_VIOLATION, code);
},
})
: undefined;
const authorizeSessionRoute = async (
frame: OneTalkAuthenticatedClientFrame,
): Promise<number | null> => {
@@ -260,6 +286,34 @@ export const createOneTalkPluginWebSocketHandler = (
});
},
),
defineOneTalkEndpointRouteHandler(
"rendered.card.observed",
async (frame) => {
const epoch = await authorizeSessionRoute(frame);
if (epoch === null || !renderedCardFlow) return;
const canonical =
context.options.registry.getCanonicalConnection(socket);
const source = canonical
? sourceContextForConnection(canonical)
: null;
if (!canonical || !source)
return context.sendError(
frame,
ONETALK_ERROR_CODES.authorizationRejected,
);
await renderedCardFlow.handle({
socket,
frame,
context: source,
guard: context.options.registry.createCommitGuard(
canonical,
epoch,
),
policyEpoch: epoch,
canonical,
});
},
),
defineOneTalkEndpointRouteHandler(
"conversations.discovered",
async (frame) => {
+7
View File
@@ -58,6 +58,12 @@ export type OneTalkConnectionRegistry = OneTalkPluginPresenceReader & {
scope: OneTalkMindScope;
policyEpoch?: number;
}) => Promise<void>;
publishMessageUpdated: (input: {
message: OneTalkCenterMessage;
requestId: string;
scope: OneTalkMindScope;
policyEpoch?: number;
}) => Promise<void>;
publishSyncStatus: (input: {
requestId: string;
scope: OneTalkMindScope;
@@ -257,6 +263,7 @@ export const createOneTalkConnectionRegistry = (options: {
register: store.register,
unregister: store.unregister,
publishMessageCreated: publisher.publishMessageCreated,
publishMessageUpdated: publisher.publishMessageUpdated,
publishSyncStatus: publisher.publishSyncStatus,
publishConversationUpdated: publisher.publishConversationUpdated,
isPluginOnline: store.isPluginOnline,
@@ -575,6 +575,20 @@ test("projects one persisted product fact identically and rejects raw-query JSON
);
});
test("rejects malformed rendered-card JSONB at the read boundary", () => {
assert.throws(
() =>
projectCenterMessage({
...message(),
renderedCardContent: {
version: 1,
kind: "rendered_order",
} as never,
}),
/Invalid persisted OneTalk message content/,
);
});
test("CenterMessage accepts normalized media and structured-card values", () => {
const verifiedImage = {
...messageFieldsForProjectionTest(),
@@ -0,0 +1,116 @@
// 验证渲染卡片补全的 ACK 与实时发布终态
import assert from "node:assert/strict";
import test from "node:test";
import {
createOneTalkRenderedCardContentFingerprint,
createOneTalkRenderedCardObservedFrame,
type OneTalkFrame,
} from "@trade-message-center/onetalk-contract";
import { createOneTalkRenderedCardFlow } from "../src/websocket/plugin/flows/rendered-card-flow.ts";
const scope = { channelAccountId: "account-1", deviceId: "device-1" };
const context = {
channelAccountId: "account-1",
deviceId: "device-1",
binding: "binding-1",
mindUserId: "mind-user-1",
workspaceId: "workspace-1",
};
const content = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const frame = createOneTalkRenderedCardObservedFrame(
{ connectionType: "plugin", requestId: "request-1", scope },
{
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: createOneTalkRenderedCardContentFingerprint(content),
observedAtMs: 100,
},
);
const message = {
messageId: "message-1",
conversationId: "conversation-1",
senderId: "sender-1",
participantIds: ["sender-1", "receiver-1"],
direction: "received" as const,
sentAtMs: 99,
content,
};
for (const status of ["accepted", "duplicate", "conflict", "rejected"] as const) {
test(`ACKs rendered-card ${status} and publishes only acceptance`, async () => {
const sent: OneTalkFrame[] = [];
const published: unknown[] = [];
const flow = createOneTalkRenderedCardFlow({
service: {
ingest: async () =>
status === "rejected"
? { status, reason: "base_message_missing" as const }
: { status, content, message },
},
registry: {
getCanonicalConnection: () => canonical,
publishMessageUpdated: async (input: unknown) => published.push(input),
} as never,
sendFrame: (outbound) => {
sent.push(outbound);
return true;
},
isPolicyCurrent: () => true,
isCommitGuardFailure: () => false,
closeForPause: () => assert.fail("unexpected pause"),
closeForDatabaseFailure: () => assert.fail("unexpected database failure"),
closeForAcknowledgementFailure: () => assert.fail("unexpected ack failure"),
reauthorize: async () => ({ ok: true }),
handleAuthorizationFailure: () => assert.fail("unexpected authorization failure"),
});
const canonical = {} as never;
await flow.handle({
socket: {} as never,
frame,
context,
guard: { assertValid: () => undefined },
policyEpoch: 1,
canonical,
});
assert.equal(sent.length, 1);
assert.equal(sent[0]?.type, "rendered.card.ack");
assert.deepEqual(sent[0]?.payload, {
conversationId: "conversation-1",
messageId: "message-1",
contentFingerprint: frame.payload.contentFingerprint,
observedAtMs: 100,
status,
...(status === "rejected"
? { rejectionCode: "base_message_missing" }
: status === "conflict"
? { rejectionCode: "content_conflict" }
: {}),
});
assert.equal(published.length, status === "accepted" ? 1 : 0);
if (status === "accepted") {
assert.deepEqual(published[0], {
message,
requestId: "request-1",
scope: {
mindUserId: "mind-user-1",
workspaceId: "workspace-1",
channelAccountId: "account-1",
},
policyEpoch: 1,
});
}
});
}
@@ -0,0 +1,48 @@
// 验证渲染卡片补全使用独立、带备注的追加迁移。
import assert from "node:assert/strict";
import { readdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import test from "node:test";
import { ONETALK_INITIAL_HISTORY_GENERATION } from "@trade-message-center/onetalk-contract";
import { migrationDirectory } from "../src/database/migration-config.ts";
import { ONETALK_SCHEMA_INITIAL_HISTORY_GENERATION } from "../src/database/schema/onetalk.ts";
test("keeps the CJS-safe schema history default aligned with the shared contract", () => {
assert.equal(ONETALK_SCHEMA_INITIAL_HISTORY_GENERATION, ONETALK_INITIAL_HISTORY_GENERATION);
});
test("adds the rendered-card fact table in the latest additive migration", async () => {
const directory = resolve(process.cwd(), migrationDirectory);
const files = (await readdir(directory)).filter((file) => /^\d+_.+\.sql$/u.test(file)).sort();
const latest = files.at(-1);
assert.ok(latest, "expected at least one SQL migration");
const sql = await readFile(resolve(directory, latest), "utf8");
assert.match(sql, /CREATE TABLE "onetalk_rendered_card_content"/i);
assert.match(
sql,
/PRIMARY KEY\s*\("channel_account_id",\s*"conversation_id",\s*"message_id"\)/i,
);
assert.match(sql, /COMMENT ON TABLE "onetalk_rendered_card_content"/i);
for (const column of [
"channel_account_id",
"conversation_id",
"message_id",
"rendered_card_content",
"rendered_card_content_fingerprint",
"rendered_card_observed_at_ms",
"first_confirmed_at",
"last_observed_at",
"conflict_count",
"last_conflicting_fingerprint",
"last_conflict_observed_at_ms",
]) {
assert.match(
sql,
new RegExp(`COMMENT ON COLUMN "onetalk_rendered_card_content"."${column}"`, "i"),
);
}
});
@@ -9,6 +9,7 @@ import {
ONETALK_ERROR_CODES,
ONETALK_PROTOCOL_VERSION,
createOneTalkBuyerFactFingerprint,
createOneTalkRenderedCardContentFingerprint,
createMockAuthorizationReader,
decodeOneTalkFrame,
type MockAuthorizationRecord,
@@ -50,6 +51,7 @@ const CLIENT_FRAME_COVERAGE_BASELINES = {
"buyer.facts.observed": ["buyer.facts.ack"],
"message.observed": ["message.ack", "message.created", "conversation.updated"],
"messages.observed": ["messages.ack"],
"rendered.card.observed": ["rendered.card.ack", "message.updated"],
"storage.delete.ack": [],
"history.sync.ack": [],
"send.request": ["send.command"],
@@ -426,6 +428,7 @@ test("executes a wire assertion for every declared OneTalk client frame", async
status: "accepted" as const,
message: observedMessage,
}));
const renderedCardInputs: unknown[] = [];
const app = createApp(testConfig, {
database: createDatabaseStub(),
authorization: createMockAuthorizationReader([
@@ -448,6 +451,19 @@ test("executes a wire assertion for every declared OneTalk client frame", async
factCount: facts.length,
}),
},
renderedCardService: {
ingest: async (input) => {
renderedCardInputs.push(input);
return {
status: "accepted" as const,
content: input.observation.content,
message: {
...centerMessage(message(input.observation.messageId)),
content: input.observation.content,
},
};
},
},
readService: {
listConversations: async () => ({
status: "accepted" as const,
@@ -790,6 +806,57 @@ test("executes a wire assertion for every declared OneTalk client frame", async
assert.deepEqual([commandFrame.type], CLIENT_FRAME_COVERAGE_BASELINES["send.request"]);
recordCoverage("send.request", [commandFrame]);
const renderedCardContent = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Matrix order",
products: [],
productCount: 0,
status: { code: null, text: "Paid" },
payment: { totalDisplay: "$1", discountDisplay: null },
delivery: { shippingAddress: "Matrix address", methodLabel: null, dateLabel: null },
action: { label: null, status: null },
};
const renderedCardAck = nextMessage(plugin);
const renderedCardUpdate = nextFrame(mind, (frame) => frame.type === "message.updated");
plugin.send(
JSON.stringify({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "rendered.card.observed",
requestId: "matrix-rendered-card",
scope: pluginScope,
payload: {
conversationId: "conversation-1",
messageId: "matrix-rendered-card-message",
content: renderedCardContent,
contentFingerprint:
createOneTalkRenderedCardContentFingerprint(renderedCardContent),
observedAtMs: 1_700_000_000_100,
},
}),
);
const renderedCardFrames = [await renderedCardAck, await renderedCardUpdate];
assert.deepEqual(
renderedCardFrames.map((frame) => frame.type),
CLIENT_FRAME_COVERAGE_BASELINES["rendered.card.observed"],
);
assert.deepEqual(renderedCardFrames[0]?.payload, {
conversationId: "conversation-1",
messageId: "matrix-rendered-card-message",
contentFingerprint: createOneTalkRenderedCardContentFingerprint(renderedCardContent),
observedAtMs: 1_700_000_000_100,
status: "accepted",
});
assert.deepEqual(renderedCardFrames[1]?.payload, {
message: {
...centerMessage(message("matrix-rendered-card-message")),
content: renderedCardContent,
},
});
assert.equal(renderedCardInputs.length, 1);
recordCoverage("rendered.card.observed", renderedCardFrames);
const sentMessage = { ...message("matrix-sent"), direction: "sent" as const };
const confirmationFrames = nextMessages(mind, 3);
plugin.send(
+6 -1
View File
@@ -2,6 +2,7 @@
import { isOneTalkAvatarUrl, isPlainRecord } from "./guards.ts";
import { ONETALK_DIRECTIONS, type OneTalkDirection } from "./messages.ts";
import { isOneTalkRenderedCardContent, type OneTalkRenderedCardContent } from "./rendered-cards.ts";
export const ONETALK_CONTENT_VERSION = 1 as const;
export const ONETALK_MAX_MEDIA_SIZE_BYTES = 10 * 1024 ** 3;
@@ -110,7 +111,10 @@ export type OneTalkMessageContent =
| OneTalkOrderContent
| OneTalkProductContent;
export type OneTalkCenterMessageContent = OneTalkMessageContent | OneTalkBusinessCardViewContent;
export type OneTalkCenterMessageContent =
| OneTalkMessageContent
| OneTalkBusinessCardViewContent
| OneTalkRenderedCardContent;
/** Mind HTTP 会话与 conversation.updated 共用的公开会话读取模型。 */
export type CenterConversation = {
@@ -590,6 +594,7 @@ export const isOneTalkCenterMessageContent = (
value: unknown,
): value is OneTalkCenterMessageContent => {
if (isOneTalkMessageContent(value)) return true;
if (isOneTalkRenderedCardContent(value)) return true;
return isPlainRecord(value) && isOneTalkBusinessCardViewContent(value);
};
+8
View File
@@ -13,6 +13,7 @@ import { isValidOneTalkConversationSyncPayload } from "./conversation-sync.ts";
import { isValidOneTalkMessagePayload } from "./messages.ts";
import { isValidOneTalkSendingPayload } from "./sending.ts";
import { isValidOneTalkHistoryRebuildPayload } from "./rebuild.ts";
import { isValidOneTalkRenderedCardPayload } from "./rendered-cards.ts";
import { isPlainRecord } from "./guards.ts";
import { ONETALK_FRAME_TYPES, ONETALK_PROTOCOL_VERSION } from "./wire.ts";
import type { OneTalkFrame, OneTalkFrameContext, OneTalkFrameType } from "./wire.ts";
@@ -88,6 +89,8 @@ const hasValidFrameDirection = (
"messages.observed",
"message.ack",
"messages.ack",
"rendered.card.observed",
"rendered.card.ack",
"storage.delete.command",
"storage.delete.ack",
"history.sync.command",
@@ -103,6 +106,7 @@ const hasValidFrameDirection = (
if (
[
"message.created",
"message.updated",
"plugin.status",
"sync.status",
"conversation.updated",
@@ -163,10 +167,14 @@ const isValidPayload = (
"message.ack",
"messages.ack",
"message.created",
"message.updated",
].includes(type)
) {
return isValidOneTalkMessagePayload(type, value);
}
if (["rendered.card.observed", "rendered.card.ack"].includes(type)) {
return isValidOneTalkRenderedCardPayload(type, value);
}
if (
[
"storage.delete.command",
+29 -1
View File
@@ -102,6 +102,7 @@ export {
createOneTalkMessagesObservedFrame,
createOneTalkMessagesAckFrame,
createOneTalkMessageCreatedFrame,
createOneTalkMessageUpdatedFrame,
} from "./messages.ts";
export type {
OneTalkObservationSource,
@@ -116,8 +117,35 @@ export type {
OneTalkMessageAckFrame,
OneTalkMessagesAckFrame,
OneTalkMessageCreatedFrame,
OneTalkMessageUpdatedFrame,
} from "./messages.ts";
export {
ONETALK_RENDERED_CARD_CONTENT_VERSION,
ONETALK_RENDERED_CARD_ACK_STATUSES,
cloneOneTalkRenderedCardContent,
createOneTalkRenderedCardAckFrame,
createOneTalkRenderedCardContentFingerprint,
createOneTalkRenderedCardObservedFrame,
isOneTalkRenderedCardContent,
isOneTalkRenderedCardImageUrl,
isSameOneTalkRenderedCardContent,
isValidOneTalkRenderedCardPayload,
} from "./rendered-cards.ts";
export type {
OneTalkRenderedCardAckStatus,
OneTalkRenderedCardAction,
OneTalkRenderedCardAckFrame,
OneTalkRenderedCardContent,
OneTalkRenderedCardImage,
OneTalkRenderedCardObservation,
OneTalkRenderedCardObservedFrame,
OneTalkRenderedCardQuantity,
OneTalkRenderedInquiryContent,
OneTalkRenderedOrderContent,
OneTalkRenderedProductContent,
} from "./rendered-cards.ts";
export {
ONETALK_CONTACT_PROFILE_STATUSES,
ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE,
@@ -203,9 +231,9 @@ export {
export type { OneTalkHttpConversation, OneTalkHttpCustomerProfile } from "./read-api.ts";
export {
ONETALK_INITIAL_HISTORY_GENERATION,
ONETALK_HISTORY_REBUILD_STAGES,
ONETALK_HISTORY_REBUILD_REASONS,
ONETALK_INITIAL_HISTORY_GENERATION,
isOneTalkHistoryGeneration,
isValidOneTalkHistoryRebuildPayload,
createOneTalkStorageDeleteCommandFrame,
+18
View File
@@ -84,6 +84,11 @@ export type OneTalkMessageCreatedFrame = OneTalkBaseFrame<
{ message: OneTalkCenterMessage },
"mind_page"
>;
export type OneTalkMessageUpdatedFrame = OneTalkBaseFrame<
"message.updated",
{ message: OneTalkCenterMessage },
"mind_page"
>;
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
@@ -179,6 +184,7 @@ export const isValidOneTalkMessagePayload = (type: string, value: unknown): bool
value.results.every(isAck)
);
case "message.created":
case "message.updated":
return hasExactKeys(value, ["message"]) && isOneTalkCenterMessage(value.message);
default:
return false;
@@ -266,3 +272,15 @@ export const createOneTalkMessageCreatedFrame = (
scope: frame.scope,
payload: { message },
});
/** 创建仅替换有效内容的 Mind 实时消息更新帧。 */
export const createOneTalkMessageUpdatedFrame = (
frame: OneTalkFrameContext & { connectionType: "mind_page"; scope: OneTalkMindScope },
message: OneTalkCenterMessage,
): OneTalkMessageUpdatedFrame => ({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: frame.connectionType,
type: "message.updated",
requestId: frame.requestId,
scope: frame.scope,
payload: { message },
});
@@ -0,0 +1,339 @@
// 定义 OneTalk 已渲染卡片补全的严格协议
import type { OneTalkMindScope, OneTalkPluginScope } from "./connection.ts";
import { isPlainRecord } from "./guards.ts";
import { ONETALK_PROTOCOL_VERSION } from "./wire.ts";
import type { OneTalkBaseFrame, OneTalkFrameContext } from "./wire.ts";
export const ONETALK_RENDERED_CARD_CONTENT_VERSION = 1 as const;
export const ONETALK_RENDERED_CARD_ACK_STATUSES = [
"accepted",
"duplicate",
"conflict",
"rejected",
] as const;
export type OneTalkRenderedCardAckStatus = (typeof ONETALK_RENDERED_CARD_ACK_STATUSES)[number];
export type OneTalkRenderedCardImage = { imageUrl: string | null; title: string };
export type OneTalkRenderedCardQuantity = { value: string; unit: string };
export type OneTalkRenderedCardAction = { label: string; available: boolean };
export type OneTalkRenderedInquiryContent = {
version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION;
kind: "rendered_inquiry";
product: OneTalkRenderedCardImage;
purchaseQuantity: OneTalkRenderedCardQuantity;
requirementText: string;
inquiryReference: string;
actions: OneTalkRenderedCardAction[];
};
export type OneTalkRenderedProductContent = {
version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION;
kind: "rendered_product";
storeImageUrl: string | null;
product: OneTalkRenderedCardImage & { sourceUrl: string; productId: string };
priceDisplay: string;
minimumOrder: OneTalkRenderedCardQuantity;
serviceBadges: string[];
};
export type OneTalkRenderedOrderContent = {
version: typeof ONETALK_RENDERED_CARD_CONTENT_VERSION;
kind: "rendered_order";
title: string;
products: OneTalkRenderedCardImage[];
productCount: number;
status: { code: string | null; text: string };
payment: { totalDisplay: string; discountDisplay: string | null };
delivery: { shippingAddress: string; methodLabel: string | null; dateLabel: string | null };
action: { label: string | null; status: string | null };
};
export type OneTalkRenderedCardContent =
| OneTalkRenderedInquiryContent
| OneTalkRenderedProductContent
| OneTalkRenderedOrderContent;
export type OneTalkRenderedCardObservation = {
conversationId: string;
messageId: string;
content: OneTalkRenderedCardContent;
contentFingerprint: string;
observedAtMs: number;
};
export type OneTalkRenderedCardObservedFrame = OneTalkBaseFrame<
"rendered.card.observed",
OneTalkRenderedCardObservation,
"plugin"
>;
export type OneTalkRenderedCardAckFrame = OneTalkBaseFrame<
"rendered.card.ack",
Pick<
OneTalkRenderedCardObservation,
"conversationId" | "messageId" | "contentFingerprint" | "observedAtMs"
> & {
status: OneTalkRenderedCardAckStatus;
rejectionCode?: "base_message_missing" | "content_conflict" | "invalid_card";
},
"plugin"
>;
const MAX_TEXT_LENGTH = 64 * 1024;
const MAX_IDENTIFIER_LENGTH = 512;
const MAX_ITEMS = 100;
const CARD_IMAGE_HOSTS = new Set([
"onetalk.alibaba.com",
"img.alicdn.com",
"cbu01.alicdn.com",
"sc04.alicdn.com",
]);
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 isText = (value: unknown, maximum = MAX_TEXT_LENGTH): value is string =>
typeof value === "string" &&
value.trim().length > 0 &&
value.length <= maximum &&
!/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(value);
const isIdentifier = (value: unknown): value is string => isText(value, MAX_IDENTIFIER_LENGTH);
const isSafeNonNegativeInteger = (value: unknown): value is number =>
typeof value === "number" && Number.isSafeInteger(value) && value >= 0;
/** 验证不含认证信息、query 或 fragment 的批准卡片图片 URL。 */
export const isOneTalkRenderedCardImageUrl = (value: unknown): value is string => {
if (typeof value !== "string" || value.length === 0 || value.length > 8 * 1024) return false;
try {
const url = new URL(value);
return (
url.protocol === "https:" &&
CARD_IMAGE_HOSTS.has(url.hostname) &&
url.port === "" &&
url.username === "" &&
url.password === "" &&
url.search === "" &&
url.hash === ""
);
} catch {
return false;
}
};
const isNullableImageUrl = (value: unknown): value is string | null =>
value === null || isOneTalkRenderedCardImageUrl(value);
const isCardImage = (value: unknown): value is OneTalkRenderedCardImage =>
isPlainRecord(value) &&
hasExactKeys(value, ["imageUrl", "title"]) &&
isNullableImageUrl(value.imageUrl) &&
isText(value.title);
const isQuantity = (value: unknown): value is OneTalkRenderedCardQuantity =>
isPlainRecord(value) &&
hasExactKeys(value, ["value", "unit"]) &&
isText(value.value) &&
isText(value.unit);
const isAction = (value: unknown): value is OneTalkRenderedCardAction =>
isPlainRecord(value) &&
hasExactKeys(value, ["label", "available"]) &&
isText(value.label) &&
typeof value.available === "boolean";
const canonicalize = (value: unknown): string => {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
const record = value as Record<string, unknown>;
return `{${Object.keys(record)
.sort()
.map((key) => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
.join(",")}}`;
};
/** 生成稳定的非秘密内容指纹;服务端仍以完整快照确认重复。 */
export const createOneTalkRenderedCardContentFingerprint = (
content: OneTalkRenderedCardContent,
): string => {
let hash = 0x811c9dc5;
for (const byte of new TextEncoder().encode(canonicalize(content))) {
hash ^= byte;
hash = Math.imul(hash, 0x01000193);
}
return `v1-${(hash >>> 0).toString(16).padStart(8, "0")}`;
};
/** 以字段语义而非 JSON 插入顺序比较已验证卡片快照。 */
export const isSameOneTalkRenderedCardContent = (
left: OneTalkRenderedCardContent,
right: OneTalkRenderedCardContent,
): boolean => canonicalize(left) === canonicalize(right);
/** 克隆跨世界传输的受限卡片快照,拒绝保留 Fiber 或 raw props 引用。 */
export const cloneOneTalkRenderedCardContent = (
content: OneTalkRenderedCardContent,
): OneTalkRenderedCardContent => JSON.parse(canonicalize(content)) as OneTalkRenderedCardContent;
/** 解码精确 shape 的受控渲染卡片内容。 */
export const isOneTalkRenderedCardContent = (
value: unknown,
): value is OneTalkRenderedCardContent => {
if (!isPlainRecord(value) || value.version !== ONETALK_RENDERED_CARD_CONTENT_VERSION)
return false;
if (value.kind === "rendered_inquiry") {
return (
hasExactKeys(value, [
"version",
"kind",
"product",
"purchaseQuantity",
"requirementText",
"inquiryReference",
"actions",
]) &&
isCardImage(value.product) &&
isQuantity(value.purchaseQuantity) &&
isText(value.requirementText) &&
isIdentifier(value.inquiryReference) &&
Array.isArray(value.actions) &&
value.actions.length <= MAX_ITEMS &&
value.actions.every(isAction)
);
}
if (value.kind === "rendered_product") {
const product = value.product;
return (
hasExactKeys(value, [
"version",
"kind",
"storeImageUrl",
"product",
"priceDisplay",
"minimumOrder",
"serviceBadges",
]) &&
isNullableImageUrl(value.storeImageUrl) &&
isPlainRecord(product) &&
hasExactKeys(product, ["imageUrl", "title", "sourceUrl", "productId"]) &&
isNullableImageUrl(product.imageUrl) &&
isText(product.title) &&
isOneTalkRenderedCardImageUrl(product.sourceUrl) &&
isIdentifier(product.productId) &&
isText(value.priceDisplay) &&
isQuantity(value.minimumOrder) &&
Array.isArray(value.serviceBadges) &&
value.serviceBadges.length <= MAX_ITEMS &&
value.serviceBadges.every((badge) => isText(badge))
);
}
if (value.kind === "rendered_order") {
const status = value.status;
const payment = value.payment;
const delivery = value.delivery;
const action = value.action;
return (
hasExactKeys(value, [
"version",
"kind",
"title",
"products",
"productCount",
"status",
"payment",
"delivery",
"action",
]) &&
isText(value.title) &&
Array.isArray(value.products) &&
value.products.length <= MAX_ITEMS &&
value.products.every(isCardImage) &&
isSafeNonNegativeInteger(value.productCount) &&
isPlainRecord(status) &&
hasExactKeys(status, ["code", "text"]) &&
(status.code === null || isIdentifier(status.code)) &&
isText(status.text) &&
isPlainRecord(payment) &&
hasExactKeys(payment, ["totalDisplay", "discountDisplay"]) &&
isText(payment.totalDisplay) &&
(payment.discountDisplay === null || isText(payment.discountDisplay)) &&
isPlainRecord(delivery) &&
hasExactKeys(delivery, ["shippingAddress", "methodLabel", "dateLabel"]) &&
isText(delivery.shippingAddress) &&
(delivery.methodLabel === null || isText(delivery.methodLabel)) &&
(delivery.dateLabel === null || isText(delivery.dateLabel)) &&
isPlainRecord(action) &&
hasExactKeys(action, ["label", "status"]) &&
(action.label === null || isText(action.label)) &&
(action.status === null || isText(action.status))
);
}
return false;
};
const isObservation = (value: unknown): value is OneTalkRenderedCardObservation =>
isPlainRecord(value) &&
hasExactKeys(value, [
"conversationId",
"messageId",
"content",
"contentFingerprint",
"observedAtMs",
]) &&
isIdentifier(value.conversationId) &&
isIdentifier(value.messageId) &&
isOneTalkRenderedCardContent(value.content) &&
isIdentifier(value.contentFingerprint) &&
isSafeNonNegativeInteger(value.observedAtMs) &&
value.contentFingerprint === createOneTalkRenderedCardContentFingerprint(value.content);
/** 验证 rendered-card wire payload,不允许 raw props 或额外字段越界。 */
export const isValidOneTalkRenderedCardPayload = (type: string, value: unknown): boolean => {
if (!isPlainRecord(value)) return false;
if (type === "rendered.card.observed") return isObservation(value);
if (type !== "rendered.card.ack") return false;
return (
hasExactKeys(value, [
"conversationId",
"messageId",
"contentFingerprint",
"observedAtMs",
"status",
...(value.rejectionCode === undefined ? [] : ["rejectionCode"]),
]) &&
isIdentifier(value.conversationId) &&
isIdentifier(value.messageId) &&
isIdentifier(value.contentFingerprint) &&
isSafeNonNegativeInteger(value.observedAtMs) &&
typeof value.status === "string" &&
ONETALK_RENDERED_CARD_ACK_STATUSES.includes(value.status as OneTalkRenderedCardAckStatus) &&
(value.rejectionCode === undefined ||
["base_message_missing", "content_conflict", "invalid_card"].includes(
value.rejectionCode as string,
))
);
};
/** 创建已验证渲染卡片 observation frame。 */
export const createOneTalkRenderedCardObservedFrame = (
frame: OneTalkFrameContext & { connectionType: "plugin"; scope: OneTalkPluginScope },
observation: OneTalkRenderedCardObservation,
): OneTalkRenderedCardObservedFrame => ({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "rendered.card.observed",
requestId: frame.requestId,
scope: frame.scope,
payload: { ...observation, content: cloneOneTalkRenderedCardContent(observation.content) },
});
/** 创建对应精确 observation 的服务端 ACK。 */
export const createOneTalkRenderedCardAckFrame = (
frame: OneTalkFrameContext & { connectionType: "plugin"; scope: OneTalkPluginScope },
payload: OneTalkRenderedCardAckFrame["payload"],
): OneTalkRenderedCardAckFrame => ({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "rendered.card.ack",
requestId: frame.requestId,
scope: frame.scope,
payload: { ...payload },
});
export type OneTalkRenderedCardMindScope = OneTalkMindScope;
+15 -1
View File
@@ -27,10 +27,15 @@ import type { OneTalkBuyerFactsAckFrame, OneTalkBuyerFactsObservedFrame } from "
import type {
OneTalkMessageAckFrame,
OneTalkMessageCreatedFrame,
OneTalkMessageUpdatedFrame,
OneTalkMessageObservedFrame,
OneTalkMessagesAckFrame,
OneTalkMessagesObservedFrame,
} from "./messages.ts";
import type {
OneTalkRenderedCardAckFrame,
OneTalkRenderedCardObservedFrame,
} from "./rendered-cards.ts";
import type {
OneTalkSendCommandFrame,
OneTalkSendConfirmationFrame,
@@ -45,7 +50,7 @@ import type {
OneTalkStorageDeleteCommandFrame,
} from "./rebuild.ts";
export const ONETALK_PROTOCOL_VERSION = 7 as const;
export const ONETALK_PROTOCOL_VERSION = 8 as const;
export const ONETALK_FRAME_TYPES = [
"ws.hello",
@@ -71,6 +76,9 @@ export const ONETALK_FRAME_TYPES = [
"message.ack",
"messages.ack",
"message.created",
"message.updated",
"rendered.card.observed",
"rendered.card.ack",
"send.request",
"send.command",
"send.confirmation",
@@ -93,6 +101,7 @@ export const ONETALK_CLIENT_FRAME_TYPES = [
"buyer.facts.observed",
"message.observed",
"messages.observed",
"rendered.card.observed",
"send.request",
"send.confirmation",
"storage.delete.ack",
@@ -114,6 +123,8 @@ export const ONETALK_SERVER_FRAME_TYPES = [
"message.ack",
"messages.ack",
"message.created",
"message.updated",
"rendered.card.ack",
"send.command",
"send.result",
"storage.delete.command",
@@ -175,6 +186,9 @@ export type OneTalkFrame =
| OneTalkMessageAckFrame
| OneTalkMessagesAckFrame
| OneTalkMessageCreatedFrame
| OneTalkMessageUpdatedFrame
| OneTalkRenderedCardObservedFrame
| OneTalkRenderedCardAckFrame
| OneTalkSendRequestFrame
| OneTalkSendCommandFrame
| OneTalkSendConfirmationFrame
@@ -28,6 +28,9 @@ import {
createOneTalkConversationsAckFrame,
createOneTalkConversationsDiscoveredFrame,
createOneTalkMessageAckFrame,
createOneTalkMessageUpdatedFrame,
createOneTalkRenderedCardContentFingerprint,
createOneTalkRenderedCardObservedFrame,
createOneTalkMessagesAckFrame,
createOneTalkMessagesObservedFrame,
createOneTalkPluginStatusFrame,
@@ -52,6 +55,8 @@ import {
isNextOneTalkConversationsDiscoveredFragment,
isOneTalkMessage,
isOneTalkMessageContent,
isOneTalkRenderedCardContent,
isSameOneTalkRenderedCardContent,
normalizeOneTalkProductUrl,
type OneTalkMindScope,
type MockAuthorizationRecord,
@@ -122,6 +127,75 @@ const textContent = {
text: "hello",
};
test("strictly validates rendered-card frames, canonical fingerprints, and in-place updates", () => {
const content = {
version: 1 as const,
kind: "rendered_order" as const,
title: "Order summary",
products: [{ imageUrl: "https://img.alicdn.com/item.jpg", title: "Widget" }],
productCount: 1,
status: { code: "paid", text: "Paid" },
payment: { totalDisplay: "US $10.00", discountDisplay: null },
delivery: { shippingAddress: "1 Market Street", methodLabel: null, dateLabel: null },
action: { label: "View order", status: "available" },
};
assert.equal(isOneTalkRenderedCardContent(content), true);
const fingerprint = createOneTalkRenderedCardContentFingerprint(content);
const frame = createOneTalkRenderedCardObservedFrame(frameBase, {
conversationId: "conversation-1",
messageId: "message-1",
content,
contentFingerprint: fingerprint,
observedAtMs: 1_700_000_000_001,
});
assert.deepEqual(decodeOneTalkFrame(frame), { ok: true, frame });
assert.equal(
isSameOneTalkRenderedCardContent(content, {
...content,
status: { text: "Paid", code: "paid" },
}),
true,
);
assert.deepEqual(
decodeOneTalkFrame({
...frame,
payload: { ...frame.payload, contentFingerprint: "forged" },
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
);
assert.deepEqual(
decodeOneTalkFrame({
...frame,
payload: {
...frame.payload,
content: {
...content,
products: [
{
...content.products[0],
imageUrl: "https://img.alicdn.com/item.jpg?token=x",
},
],
},
},
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
);
const updated = createOneTalkMessageUpdatedFrame(
{ connectionType: "mind_page", requestId: "request-2", scope: mindScope },
{
messageId: "message-1",
conversationId: "conversation-1",
senderId: "sender-1",
participantIds: ["sender-1", "receiver-1"],
direction: "received",
sentAtMs: 1_700_000_000_000,
content,
},
);
assert.deepEqual(decodeOneTalkFrame(updated), { ok: true, frame: updated });
});
const jpegContent = {
version: ONETALK_CONTENT_VERSION,
kind: "image" as const,