mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: update message synchronization flow
This commit is contained in:
@@ -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.';
|
||||
@@ -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": {}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
/** 该技术会话已提交的消息事实数量。 */
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
});
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user