Merge pull request #49 from sinanyuntu/09-12-onetalk-profile-active-sync

完善 OneTalk 联系人资料主动同步链路
This commit is contained in:
YBF
2026-09-13 14:30:56 +08:00
committed by GitHub
28 changed files with 923 additions and 149 deletions
@@ -40,6 +40,11 @@ OneTalkContactProfileStore.putPendingProfile(
channelAccountId: string,
profile: OneTalkContactProfile,
): Promise<OneTalkContactProfileLedgerRecord>;
OneTalkContactProfileStore.getProfile(
channelAccountId: string,
conversationId: string,
): Promise<OneTalkContactProfileLedgerRecord | null>;
OneTalkContactProfileStore.hasProfileRecord(channelAccountId: string): Promise<boolean>;
OneTalkContactProfileStore.markProfileUploaded(input: {
channelAccountId: string;
conversationId: string;
@@ -61,6 +66,9 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
### Source and identity
- MAIN world constructs a new whitelist object from `window.__conversationListData__` and `im-conversation-list:syncData`; it never forwards a raw row or response.
- `onetalk.contact.snapshot` is an account-level command with the exact payload `{ action: "onetalk.contact.snapshot" }`; it reads only already-loaded direct profiles. It is attempted once only when the same account has a usable page, Bright is authenticated, and the durable ledger has no record for that account. Repeated hello, replay, selection change, Port reconnect or Bright reconnect must not create a second initial snapshot.
- `onetalk.contact.collect` has the exact payload `{ action: "onetalk.contact.collect", conversationId: string }`. MAIN finds only that direct conversation in the current list; it does not use the selected conversation, switch UI, scan history or infer a missing identity. `syncData` may still publish a previously seen direct profile; publication is not upload eligibility.
- A live `messageType: "new"` from either direction requests a targeted collect for its own `conversationId`. History and manual history sync never request a profile collect.
- `channelAccountId` comes only from the logged-in page identity (`currentUserAccountId` or `IcbuIM.UserUtil.currentUser.accountId`). URL `activeAccountId` identifies the selected counterpart and is never an account fallback.
- The durable profile key is `[channelAccountId, conversationId]`; `aliId` remains a profile field and does not define ownership. A profile may be persisted before the technical conversation row is discovered.
- Profile observation is intentionally independent from message observation: `__conversationListData__` and message history/live callbacks may arrive in either order. Do not join a profile snapshot into a `business_card` while decoding a message; the message fact stores only the marker and the server read path may later use this current profile by the scoped key.
@@ -69,7 +77,7 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
### Bright WebSocket boundary
- Protocol version is `5`. `contact.profile.observed` is a plugin-direction frame with `profiles.length` in `1..100` and serialized UTF-8 size at most `256 KiB`; every profile has the exact 13 whitelisted fields above.
- Protocol version is `5`. `contact.profile.observed` is a plugin-direction frame with `profiles.length` in `1..100` and serialized UTF-8 size at most `256 KiB`; the extension additionally emits at most 50 profiles per frame. Every profile has the exact 13 whitelisted fields above.
- `contact.profile.ack` is a plugin-direction frame with `{ status: "delivered"; profileCount: number }`. It means the Bright transaction and its authorization/connection fences completed; it is not a Mind HTTP response and does not mean CRM business data was committed elsewhere.
- Profile ingestion uses the existing plugin binding, `sync` operation and `read` permission. No second socket, page credential, Cookie, Mind user/workspace field or profile HTTP endpoint is introduced.
- If any profile has `observedAtMs > receivedAtMs + 5 minutes`, Bright rejects the whole batch with `ws.error` code `profile_observed_at_future`; it performs no profile DB write and sends no profile ACK. The coordinator discards only the matching pending request entries so the rejected batch is not retried forever.
@@ -77,7 +85,8 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
### Durable lifecycle
- `ONE_TALK_SYNC_DATABASE_VERSION` is `8`. The independent `onetalk_contact_profiles` store is keyed by the account/conversation pair; message, candidate, checkpoint and anomaly stores remain separate.
- Observation writes pending state before Bright send. Within a record, a newer `observedAtMs` replaces an older pending snapshot. A profile with an `observedAtMs` at or below the uploaded high-water mark is skipped, even when its fingerprint differs; an equal fingerprint with a newer time advances the durable high-water mark without reopening pending.
- The durable profile fingerprint is the only upload-deduplication gate. For every received profile, read its `[channelAccountId, conversationId]` record before `putPendingProfile()`. If either `pending.fingerprint` or `lastUploadedFingerprint` equals the incoming fingerprint, end after that read: do not update a timestamp/high-water mark, write IndexedDB, emit `profile_observed` or flush/send. A different fingerprint follows the existing durable-first pending write.
- Same-key observations serialize the `getProfile → putPendingProfile` transition. Concurrent equal fingerprints may each perform their own read, but at most one may write, diagnose or flush. A read failure stays observable to the caller; tracker cleanup must not create a second unhandled rejection. If the coordinator is disposed while a durable read is pending, its completion performs no write, diagnostic or flush.
- An ACK marks uploaded only after a readwrite transaction and only when the current pending fingerprint and `observedAtMs` still match. Older, unknown, duplicate or mismatched ACKs are no-ops. A future-skew error removes only the exact matching pending snapshot and records its rejected observation watermark.
- Reconnect, Service Worker restart and a newly authenticated Bright client rebuild sends from durable pending records. There is no profile deletion, TTL, or fallback to a message row/Mind database.
- Page/configuration callbacks use page, connection and configuration identity fences. A stale callback cannot route a snapshot to another account or operate a replacement coordinator.
@@ -94,25 +103,28 @@ OneTalkContactProfileStore.discardPendingProfile(input: {
| Group row, missing logged-in identity or CRM identifier mismatch | drop observation; do not write the old account or unmatched customer |
| Extra frame key, sensitive key or wrong frame direction/scope | reject as `invalid_message`; no secret echo |
| Empty or over-limit profile batch | `invalid_message`; no Bright DB write, send or ACK |
| Equal/older profile observation | durable HWM/pending rules skip it; no duplicate send |
| Equal pending/uploaded fingerprint | exactly one ledger read for that observation, then no write, diagnostic, flush or send |
| Different fingerprint | durable-first pending write, `profile_observed` diagnostic and bounded flush are allowed |
| Any profile beyond the five-minute future skew | whole batch `profile_observed_at_future`; no DB write/ACK, matching pending is discarded |
| IndexedDB write/commit abort | no Bright send or uploaded state; failure remains explicit |
| Bright offline, transport error or authorization/connection fence failure | no ACK; pending remains unless the explicit future-skew error was received |
| ACK does not match request count or current fingerprint/time | no uploaded mark; pending/current record is preserved |
| Page/account/configuration epoch is stale | no observer/coordinator/command side effect |
| Auth, page/account/configuration epoch or coordinator becomes stale during the initial ledger check | no snapshot command; an old setup claim cannot block a later authenticated setup |
| Coordinator is disposed after a durable read starts | no pending write, diagnostic or flush; a real read failure remains a caller-visible failure |
## 5. Good / Base / Bad Cases
- Good: MAIN derives a fixed profile from the logged-in account, durable state commits under `[account, conversation]`, Bright receives a bounded frame, and the exact current ACK fence marks it uploaded.
- Good: an old profile arrives again through `syncData` or a targeted live collect; Service Worker reads its ledger fingerprint once and returns without a write, diagnostic or Bright frame.
- Base: a profile with nullable business fields is a valid partial snapshot; a later strictly newer snapshot may overwrite every stored field, including explicit `null`.
- Bad: serializing a conversation row, using URL `activeAccountId`, keying the ledger by `aliId`, treating an HTTP/Mind response as the upload ACK, retrying a future-skew batch, or clearing pending on send start.
- Bad: using MAIN `seen`, page selection, a timer or a second account baseline as upload dedupe; treating an equal fingerprint as a timestamp refresh; routing a live message to a full list scan; or clearing pending on send start.
## 6. Tests Required
- Contract: exact profile/frame keys, protocol version, direction/scope, direct discovery type, sensitive/unknown key rejection, empty/over-limit batches and `256 KiB` byte limit.
- Observer: page snapshot/update, group exclusion, logged-in identity, logout/account switch, duplicate fingerprints, CRM customer matching and avatar URL validation.
- Ledger: the pre-v7 (`oldVersion < 7`) upgrade clears every old OneTalk store before recreating current profile and message-sync state, and the v7→v8 upgrade keeps the five existing stores; account/conversation key, durable-first ordering, abort retention, latest pending replacement, uploaded HWM, stale different-fingerprint skip, exact ACK/CAS, future-skew discard, reconnect and restart recovery. The upgrade must preserve configuration/deviceId and must not rekey/retry an old pending ledger.
- Service Worker: existing Bright binding/read/sync authorization, profile request mapping, ACK count, future error mapping and stale callback/page identity fences; direct discovery always carries `conversationType: "direct"`.
- Observer: initial snapshot, repeatable `syncData` publication, exact snapshot/collect payload validation, targeted direct lookup, group/unknown target exclusion, logged-in identity, logout/account switch, CRM customer matching and avatar URL validation.
- Ledger: the pre-v7 (`oldVersion < 7`) upgrade clears every old OneTalk store before recreating current profile and message-sync state, and the v7→v8 upgrade keeps the five existing stores; account/conversation key, durable-first ordering, same-pending/uploaded one-read-zero-write behavior, same-key concurrent writes, dispose-during-read, explicit read rejection, ACK/CAS, future-skew discard, reconnect and restart recovery. The upgrade must preserve configuration/deviceId and must not rekey/retry an old pending ledger.
- Service Worker: existing Bright binding/read/sync authorization, first-auth setup in both page-first and auth-first order, auth-loss-after-ledger-read, live sent/received targeted collect, history exclusion, 50/51 profile batches, ACK count, future error mapping and stale callback/page identity fences; direct discovery always carries `conversationType: "direct"`.
- Direct typecheck, contract/extension focused and full tests, format check and `git diff --check` are required. Real Chromium, Bright PostgreSQL and production Mind integration are separate external checks.
## 7. Wrong vs Correct
@@ -127,6 +139,14 @@ await store.markProfileUploaded({ channelAccountId, aliId, fingerprint, uploaded
### Correct
```ts
// Do not let a page-local seen set or observedAt timestamp decide upload eligibility.
const existing = await store.getProfile(channelAccountId, profile.conversationId);
if (
existing?.pending?.fingerprint === profile.profileFingerprint ||
existing?.lastUploadedFingerprint === profile.profileFingerprint
) {
return;
}
await store.putPendingProfile(channelAccountId, profile);
const requestId = bright.sendContactProfiles({ profiles: [profile] });
// On the matching ACK, mark only the current pending [account, conversation, fingerprint, time].
@@ -152,7 +152,8 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
- 不得广播到多个标签页;
- 不得跨账号回退;
- 不得随机选择页面;
- onetalk.contact.snapshot 是唯一同账号页面的 account-level observation trigger重复相同 hello 不重复触发,snapshot result 不阻塞消息 bootstrap
- `onetalk.contact.snapshot` 是唯一同账号页面的 account-level initial observation trigger只接受精确 payload `{ action: "onetalk.contact.snapshot" }`,并且只在 Bright 已认证、页面可用且该账号 durable profile ledger 为空时由 Service Worker 请求。重复 hello、replay、选择变化或连接恢复不重复触发,snapshot result 不阻塞消息 bootstrap
- `onetalk.contact.collect` 是唯一同账号页面的 account-level targeted observation command;只接受精确 payload `{ action: "onetalk.contact.collect", conversationId }`,不要求或改变当前 selected 会话。MAIN 只能从已加载 direct list 找到该 conversationId;找不到或群聊完成返回 `profileCount: 0`,不猜测或广播;
- 账号级同步命令只发送到唯一同账号页面;
- onetalk.sync.conversation 只在同一 MAIN 页面已完成 onetalk.discover-conversations 并缓存 direct 会话后使用;它必须令 command 与 route 的 conversationId 相等,并投递到唯一同账号页面,不要求该页面当前 selected 会话就是历史目标;MAIN cache 继续是目标会话存在性的唯一证明;
- \`onetalk.sync\` 保持 \`channelAccountId + conversationId\` 的精确页面路由;
@@ -182,12 +183,15 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
| Service Worker 重新实例化 | 页面注册表为空,等待页面重新连接 |
| Profile envelope 账号与 hello/config 账号不一致 | 丢弃 profile observation,不写 ledger、不发 Bright |
| 同一 page identity 重复 hello | 保持已有 identity,不重复启动 snapshot lifecycle;活跃断线 latch 仅重放同账号 UI 状态 |
| contact snapshot/collect action 缺字段、多字段或字段类型错误 | `rejected_before_send/invalid_request`;不访问 observer、SDK 或页面状态 |
| collect target 不在当前 loaded direct list、为群聊或无登录身份 | `completed/profileCount: 0`;不切换 UI、不扫描历史、不猜测 target |
## 5. Good / Base / Bad Cases
- Good:页面先发送合法 helloService Worker 按账号和 command 类型选择唯一 Port;发送目标通过 command payload 传给 MAIN。
- Good:当前页面打开会话 A,但发送目标为会话 B;只要同账号页面唯一,command 仍投递,不改变页面 selected 状态。
- Good:全量 discovery 已在同页缓存会话 B,页面当前选中会话 Aonetalk.sync.conversation(B) 仍投递到该唯一账号页面,并由 MAIN cache 解析 B。
- Goodlive message 指向 direct 会话 B 时,Service Worker 把精确 collect(B) 投递到唯一同账号页面;B 未选中也不会改变路由结果。
- Base:页面 selected 状态变化后重新发送 hello;旧请求不会被新身份的迟到结果完成。
- Bad:将 URL \`conversationId\` 当成 SPA 会话身份、取第一个 selected 节点、广播到所有页面或跨账号回退。
- Bad:把页面 Port 当作持久化队列,或在 Port 断开后自动重新发送原 command。
@@ -201,6 +205,7 @@ Profile envelope 必须显式携带当次读取的 channelAccountId。Service Wo
- \`onetalk.send\` 在当前 selected 为其它会话、零 selected 或多 selected 时,仍向唯一同账号页面投递。
- \`onetalk.send\` 同账号零页面返回 \`waiting_for_page\`,多页面返回 \`ambiguous_page_route\`,均不广播。
- 页面 post 失败、Port 断开和身份变化都返回带 reason 的 unknown。
- contact snapshot 和 collect 都覆盖 exact payload rejectioncollect 覆盖非 selected direct target、缺失 target、群聊和缺失页面登录身份。
- 页面桥 build 产物为自包含入口,Manifest 路径、world 和 Port 名称一致。
- connection-status 覆盖 exact-shape、错误 source/version/origin/direction、无 command-result、同账号 Port fan-out 与重复 matching hello 的离线状态重放。
- raw `contentType``custom.data`、顶层 `text` 或未知 content version 均不能通过 bridgehistory/live 必须复用 MAIN 的同一 decoder。
@@ -84,15 +84,15 @@ Bright WebSocket
| `commands/index.ts` / `createOneTalkPageCommandHandler(pageWindow, dependencies)` | 返回现有 `OneTalkPageCommandHandler`,只按 action 委派 |
| `commands/send.ts` / `handleOneTalkSendCommand(pageWindow, message, sendObservation)` | 校验发送输入并执行 text 或委派原 image/file 适配 |
| `current-conversation-history/page-command.ts` | 处理 discover/sync/sync.conversation,不接收发送关联器 |
| `contact-observer/page-command.ts` / `handleOneTalkContactProfileCommand(observer)` | 调用现有 snapshot,返回 completed/profileCount;不承担 action 分发 |
| `contact-observer/page-command.ts` / `handleOneTalkContactProfileCommand(observer, command)` | 严格校验并执行 snapshot 或 targeted collect,返回 completed/profileCount;不承担 action 分发 |
#### Contracts
- 组合顺序固定为:页面 UI 能力 → 单份发送关联器 → profile observer → 共用 publisher 与命令 handler → page bridge → buyer/message/manual-history。profile 仍直接使用既有 installer,不能为合并入口而提前/推迟其安装或引入 prepare/start 状态机。
- `commands/index.ts` 将 send 委派给 send handler,将 contact.snapshot 委派给 profile handler,将三个 discover/sync 动作委派给 history handler。dispatcher 不解析业务字段,不用 handler 返回 null 逐个试路由,未知动作直接返回既有 invalid_request。
- `commands/index.ts` 将 send 委派给 send handler,将 contact.snapshot 与 contact.collect 委派给 profile handler,将三个 discover/sync 动作委派给 history handler。dispatcher 不解析业务字段,不用 handler 返回 null 逐个试路由,未知动作直接返回既有 invalid_request。
- publisher 对非空消息或有诊断的批次先调用 `observeSentMessages(batch.messages)`,再调用 `observedSink(batch)`;实时、历史命令和公开历史函数共用这一出口。采集只收到 observe 回调,不能调用 execute 或接管发送 pending。
- `message-observer/send-observation.ts` 保持发送 pending、候选 ID、timeout 和 settle 的唯一 owner;命令侧通过 execute/executeImage/executeFile 使用。`image-send.ts` 保持原生媒体上传拦截器和关联状态,不复制或搬迁模块级状态容器。
- profile snapshot 使用已安装的同一 observer,其 snapshot 自身更新 seen/CRM 相关状态并发布资料;命令只返回统计,不能再 publish。
- profile observer 可将 `syncData` 的 direct profiles 再次发布;它不维护 `seen`、baseline 或其他上传资格状态。snapshot 只读取当前已加载资料,collect 只读取一个明确 conversationId;两者都复用已安装 observer,命令只返回统计,不能再二次 publish。
- 新 installer、publisher 和 dispatcher 不拥有 Map、Set、timer、账本或连接。消息投递、profile/buyer 账本、ACK、页面请求关联及 Bright 生命周期继续归各原 Service Worker owner。
#### Validation & Error Matrix
@@ -102,7 +102,9 @@ Bright WebSocket
| batch 有消息 | 先提供给原发送关联器,再正常上报;关联成功不吞掉事实 |
| batch 无消息但有 unsupported/invalid/anomaly 诊断 | 继续发布一次诊断批次 |
| batch 无消息且无诊断 | 两个消费者都不调用 |
| contact.snapshot 在 bridge 首次 hello 后立即到达 | 使用预先安装 observer,发布一次资料并返回一个结果 |
| initial contact.snapshot 在 Bright 认证、页面可用且 ledger 为空后到达 | 使用预先安装 observer 读取当前 loaded direct profiles;重复 hello/replay/status 不再请求全量 snapshot |
| contact.collect 带精确 direct conversationId | 使用预先安装 observer 只读取该 profilehistory/手动 history 不触发 collect |
| contact snapshot/collect 多余字段、缺 conversationId 或错误类型 | rejected_before_send/invalid_request,不访问 observer 或 SDK |
| 未知 action | rejected_before_send/invalid_request,不访问 SDK 或 snapshot |
| send 的 SDK 本地接受、超时或歧义 | 保持既有发送 SOP,不增加重试或假成功 |
| 历史命令返回 batch | 使用同一 publisher,不新建发送关联器或上报管线 |
@@ -115,7 +117,7 @@ Bright WebSocket
#### Tests Required
- `onetalk-page-flow-boundaries.test.js` 覆盖 publisher 双消费顺序、诊断-only/空批次、snapshot/未知动作与实际页面入口安装顺序。
- `onetalk-page-flow-boundaries.test.js` 覆盖 publisher 双消费顺序、诊断-only/空批次、snapshot/collect exact payload、targeted collect、未知动作与实际页面入口安装顺序。
- `onetalk-send-page.test.js` 保留 send/history 的输入、结果、tooltip 和发布行为;`onetalk-send-observation.test.js` 与媒体测试保留唯一确认、timeout/异常及不重试断言。
- 对构建后的 MAIN IIFE 执行 `scripts/verify-release-bundle.mjs`,验证实际 shared observation 驱动消息上报及单次发送确认;这属于无网络 Node VM 冒烟,真实 Chromium/OneTalk/Bright 联调需单独标记。
@@ -224,6 +226,7 @@ writer.sendSendConfirmation(result);
- onetalk.send 只要求唯一同账号页面;目标会话交给 MAIN 后必须由 SDK input 的 cid 指定,conversationCode 仅为同值兼容字段,当前 selected 会话不作为发送前置条件。
- 页面身份变化、Port 断开和 Service Worker 重启必须让旧 correlation 失效;不得由迟到结果恢复旧请求。
- 相同 page identity 的重复 hello 不重复启动 snapshotprofile page/config/connection epoch 失效时不得操作替换后的 engine/coordinator。
- Profile 初始 setup 同时受 Bright authenticated、page identity、configuration epoch 和 active coordinator fence 约束;任何 await 后都要重验。ledger read 后认证丢失、页面/账号切换或 coordinator 替换时不得发送 snapshot;失效 claim 不得阻塞下一次有效 setup。
### Durable and lifecycle boundaries
@@ -233,6 +236,7 @@ writer.sendSendConfirmation(result);
- Bright 连接断开或发送结果丢失不得自动重发,不创建隐式发送任务。
- `authorization_unavailable` 表示授权依赖暂时不可用,只关闭当前 Bright socket 并沿既有连接退避自动重连;只有凭证、授权版本、binding、scope 或协议版本等确定性错误才阻断自动重连并进入 unauthorized。
- Service Worker 重启从 IndexedDB 恢复 checkpoint、候选和模式,不信任旧内存 cursor。
- live `messageType: "new"`sent 或 received)只触发所属 conversation 的 profile collecthistory 不触发。profile 相同 fingerprint 在 coordinator 的唯一 durable read 后静默结束,不能通过 MAIN `seen`、时间水位或 timer 再建第二去重状态。
- `oldVersion < 7` 的数据库先执行 v7 全量 OneTalk state 清空(v7→v8 只新增 bootstrap store、保留既有事实),再重新采集 profile、会话活动时间并执行 full sync;之后的重启/重连才从当前 ledger 恢复 pending。ACK 只在当前 `[channelAccountId, conversationId, fingerprint, observedAtMs]` 的 IndexedDB transaction commit 后生效;future-skew 整批拒绝并只丢弃匹配 pending。
### Send and protocol boundaries
@@ -0,0 +1,4 @@
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"Focused test, typecheck and build verification requirements."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"Verify profile durable-first, hash, batching, ACK and account isolation invariants."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"Verify command validation, exact page routing and stale identity fences."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Verify message/profile lifecycle separation and no duplicate page activation."}
@@ -0,0 +1,80 @@
# 技术设计:OneTalk 客户资料活跃同步
## 目标边界
基础客户资料是低优先级事实。它不再由 page hello、会话选择变化、Port 重连、Bright 重连或历史同步主动全量采集;仅有首次认证初始化、新会话和任一方向的 live 消息活动三类业务触发。页面资料可以重复跨 MAIN → bridge → Service Worker;唯一的去重与上传资格判断在 durable profile hash。
Bright WebSocket `contact.profile.observed`、服务端 profile transaction、ACK CAS、profile 白名单与 `(channelAccountId, conversationId)` ledger key 均保持不变。
## 现状与根因
`OneTalkPageRuntimeHost.handlePageIdentity()``replayTo()` 目前无条件调用 account-level `onetalk.contact.snapshot`。MAIN observer 的 `snapshot()` 每次读取完整 `__conversationListData__` 并无条件发布全部 profilecoordinator 随后逐条调用 `putPendingProfile()`。已上传的相同指纹虽不会进入 Bright frame,却仍产生 IndexedDB high-water 写入和 `profile_observed: skipped` 诊断。
已存在的 `profileFingerprint``v1-xxxxxxxx`:8 位十六进制 FNV-1a 结果,输入是当前白名单基础 profile 字段。它已被 ledger 以 `pending.fingerprint` / `lastUploadedFingerprint` 持久化,不新建 hash 或第二份 profile 状态。
## 目标数据流
```text
首次安装账号 + ws.accepted + 页面可用
-> account snapshot(页面已加载 direct profiles
-> 全部 durable 写入
-> 每帧最多 50 条发送
新会话 / syncData
-> page profile observed(允许旧会话再次经过 bridge)
-> IndexedDB hash compare
-> 相同 hash 静默结束;不同 hash 才 durable write + upload
任一 live messagesent / received
-> 唯一 conversationId
-> account-level targeted collect(不切换页面、不扫全表)
-> IndexedDB hash compare
-> hash 变化才 durable write + Bright upload
```
history、手动历史同步和 `messageType: "history"` 不走 targeted collect。
## MAIN 页面观察器与命令
扩展 `OneTalkContactProfileObserver` 为三个明确操作:
- `snapshot()`:读取当前已加载 direct profile,并发布完整 profile 列表;仅首次认证初始化使用。
- `collectConversation(conversationId)`:从当前 `__conversationListData__` 精确查找一个 direct conversation,并只发布该 profile;找不到、群聊或不完整身份返回 `profileCount: 0`,不猜测、不切换 UI。
`syncData` 与 CRM 观察保留页面侧已有的资料发布能力;它们不负责也不拥有 profile 上传去重。旧会话即使再次发布 profile,也由 Service Worker 的 durable hash 读比较静默结束。MAIN 不保存 `seen` 作为资料资格或上传 gate;新会话和 live activity 的识别仅决定何时尝试读取页面资料。
新增两个 MAIN 命令:
- `onetalk.contact.collect`account-level,命令 payload 带严格的 `conversationId`;它不是“当前选择会话”路由,必须只投递到唯一同账号页面。
保留 `onetalk.contact.snapshot` 作为首次初始化命令。两个命令在 MAIN dispatcher 中显式校验 action 与 exact payload,未知/多余字段走既有 `invalid_request`
## Service Worker 生命周期
`OneTalkPageRuntimeHost` 将 page identity 的消息同步职责与 profile 初始化职责解耦:
- page identity 仍立刻驱动 sync engine 和 buyer fact 的既有 lifecycle。
- host 记录当前 authenticated account`ws.accepted` 与 page identity 任何先后顺序都调用同一个 guarded profile setup。
- profile setup 读取 profile ledger 是否已有该 account 的任何记录:无记录时 claim 一次首次 snapshot;有记录时不发 profile page command。claim 绑定 configuration epoch、page connection epoch、active profile coordinator,防止 hello/replay/status 的竞态重复请求。
- 失败、页面不可用、配置/Port/coordinator 失效不会留下成功 claim;下一个有效认证或页面 lifecycle 可重试。成功后同一 active setup 不重复请求。
- `replayTo()` 不再直接请求 snapshot,只复用这个 setup gate。
在 profile page bridge 消息抵达时,host 先执行既有 account/coordinator fence,再交给 coordinator。对 live 消息,host 从 `OneTalkPageObservedMessage.batch` 提取 `messageType === "new"` 的唯一 conversationId,发起 `onetalk.contact.collect`。收发方向不参与筛选;history 不触发。每个 conversationId 有短生命周期 in-flight correlation,避免同一批或未完成命令重复路由;完成后下一条 live 消息仍会重新读取并比较 hash。
## Durable 去重与发送
在调用 `putPendingProfile()` 前,coordinator 对每个收到的 profile 读取同 key ledger;这是唯一去重闸门:
- `pending.fingerprint``lastUploadedFingerprint` 等于新 `profileFingerprint`:读取后直接返回。不得更新 observed timestamp/high-water、不得写 IndexedDB、不得发 `profile_observed` 诊断、不得调用 flush。
- 指纹不同或记录不存在:保持 current durable-first `putPendingProfile()`,再进入 flush。
initial snapshot 的所有输入必须先完成 durable 写入,再开始一次 flush。发送分组策略从协议上限 100 收紧为扩展本地 `50`,同时继续执行 256 KiB 实际 frame 大小检查。服务端仍可接收协议允许的 1..100,不改 contract 或数据库。
`profile_observed` 仅在实际进入 pending 的 profile 上报。首次 snapshot 可使用一条不含 profile 值的聚合诊断;相同 hash 是正常静默路径。
## 风险与回滚
- 首次 snapshot 只包含 OneTalk 页面已经加载的资料;不会新增 SDK/CRM 请求。若列表随后更新,new-conversation `syncData` 与 live message targeted collect 仍可补齐。
- 旧会话再次穿过 MAIN、bridge 和 Service Worker 是允许的低成本路径;相同 hash 只做一个 IndexedDB 读取。不要重新引入 MAIN-side `seen` 或账号级 refresh state 作为第二个去重来源。
- 8 位 FNV hash 有理论碰撞风险,但沿用已发布、低优先级资料的现有 fingerprint;本任务不改变字段或 hash 算法,避免重新采集和兼容性迁移。
- 回滚只需恢复原触发逻辑;ledger 与 server schema 均不迁移,已有 pending 仍按现有 ACK 规则恢复。
@@ -0,0 +1,5 @@
{"file":".trellis/spec/guides/index.md","reason":"Cross-layer trigger and ownership review guide."}
{"file":".trellis/spec/chrome-extension/frontend/index.md","reason":"Chrome extension package baseline and required quality checks."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md","reason":"Profile whitelist, durable ledger, ACK, identity and sensitive-field contract."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"MAIN/ISOLATED/Service Worker routing and page identity constraints."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md","reason":"Page command ownership, lifecycle and runtime composition rules."}
@@ -0,0 +1,36 @@
# 执行计划:OneTalk 客户资料活跃同步
## 1. MAIN 资料观察与命令
1. 更新 `contact-observer/entry.ts`:保留页面资料发布行为,增加 targeted conversation collect;不使用 MAIN `seen` 作为 profile 去重或上传资格判断。
2. 更新 `contact-observer/page-command.ts``main-page/commands/index.ts`:显式处理 targeted collect,严格校验 `conversationId`,保留 snapshot。
3. 更新 page command/bridge routing:新命令以 account-level 方式精确路由到唯一同账号页面,禁止按当前选择会话猜测或广播。
4. 增加 observer / MAIN command focused tests:首次 snapshot、重复资料可再次发布、targeted collect、live target missing/group、pagehide 与账号切换。
## 2. Service Worker lifecycle 与 live trigger
1.`handlePageIdentity()``replayTo()` 移除无条件 profile snapshot;保持消息/buyer 的原有 page-ready 行为。
2. 在 Bright authenticated 状态和 page identity 两端接入相同 profile setup gate;无 profile ledger record 时执行 initial snapshot,有记录时不发初始化资料命令,并覆盖两者乱序、重复 hello、reconnect、配置替换和 Port 失效。
3. 在 page observed message 的 Service Worker 边界提取唯一 live conversationId,路由 targeted collecthistory 不触发,收发方向均触发。
4. 增加 runtime/controller/session tests,确认首次认证只全量一次、已初始化账号不再发初始化命令、会话切换不 snapshot、live/history 分流和 account/page fence。
## 3. Ledger 去重与发送策略
1. 为 profile store 增加只读的 account-record existence 查询,不改变 store schema 或记录形状。
2. coordinator 对每个收到的 profile 在 `putPendingProfile()` 前读取 pending / uploaded fingerprint;相同 hash 在这次唯一 durable 读取后完全短路,不写 timestamp、不发诊断、不 flush。
3. 将 extension outbound profile batch policy 固定为 50 条;保留 shared contract 的 100 条接收上限与 256 KiB frame 检查。
4. 收敛 profile diagnostics:只报告真正 pending 的 observationinitial snapshot 用安全 aggregate,移除正常 skipped 风暴。
5. 增加 store/coordinator tests:重复跨桥 profile 的一次读/零写/零诊断/零发送、same pending、same uploaded、different hash、50/51/101 条分包、ACK CAS、offline/reconnect、future-skew 与无 profile record 的初始化判定。
## 4. 质量门与实际验证
1. 运行扩展 focused testsprofile observer、page commands/bridge、service-worker runtime、sync runtime/controller、profile store/coordinator)。
2. 运行 `pnpm --filter @trade-message-center/chrome-extension typecheck`、相关包 test 与根级 format/diff 检查。
3. 运行 `gitnexus detect_changes` 审核受影响 symbol / flow;检查 diff 不混入既有 `apps/mind-test-harness/src/config.ts` 改动。
4. 用 Chromium 验证:首次 auth 一次性 profile frames(每帧 ≤50);连续 hello/会话切换无全量 framelive sent 和 received 各触发目标会话检查;相同 hash 无 IndexedDB 写/诊断/WS frame。
## 回滚点
- MAIN command/observer 改动完成后,先跑 observer 与 bridge tests。
- lifecycle gate 完成后,先跑 runtime/controller tests;若 page identity 或 auth 顺序有回归,先回滚该 gate,不改 ACK/ledger 语义。
- batch / hash short-circuit 完成后,先跑 coordinator/store tests;不得以隐藏诊断代替验证无写入、无 flush 的真实短路。
@@ -0,0 +1,40 @@
# 收敛 OneTalk 客户资料活跃同步
## Goal
将 OneTalk 基础客户资料采集收敛为低优先级、由实际业务活动驱动的流程:首次安装后的首次 Bright 认证采集一次已加载资料;后续新会话或新消息活动可以再次把资料送到 Service Worker。已持久化的资料指纹是唯一去重闸门:相同则只做一次 IndexedDB 读取,不产生写入、`profile_observed` 诊断或 Bright 上传。
## Confirmed Facts
- 当前页面 hello、会话选择变化和 runtime replay 可触发全量 `onetalk.contact.snapshot`,其每次都会读取并发布当前 `__conversationListData__`,造成重复 `profile_observed: skipped`
- `profileFingerprint` 已是白名单基础资料生成的 `v1-xxxxxxxx`8 位十六进制 hash),以 `[channelAccountId, conversationId]` 为 key 持久化在 profile IndexedDB ledger 的 pending/last-uploaded 状态中。
- 当前 profile coordinator 会在每次 `observe()` 完成写入后立即 flush;协议上限为 100 条,但产品要求后续实际上传以 50 条为一组。
- 已归一化 live 消息带有 `messageType: "new"``direction``conversationId`,因此可作为目标会话资料检查的精确触发,而不必扫描完整会话列表。
## Requirements
1. 首次采集:仅当本次扩展安装对应的 channel account 尚无 profile ledger 记录、Bright 已认证且 OneTalk 页面可用时,采集页面已经加载的基础客户资料一次。页面 hello、会话切换、Port 重连与 Bright 重连不得重复触发该全量采集。
2. 新会话:`im-conversation-list:syncData` 的 direct profile 可以正常经过 MAIN → bridge → Service WorkerMAIN 不维护用于决定是否上传的 profile 去重状态,durable hash 是唯一的上传资格判断。
3. 新消息:收到任一方向的 live 新消息时,只检查消息所属的一个 direct conversation 的资料;不得把 history、手动历史同步或全量会话扫描当作资料采集触发。资料是否上传仍完全由 durable hash 比较决定。
4. 指纹去重:每个到达 Service Worker 的 profile 都用已有白名单字段计算现有 8 位 hash,并对同一 account/conversation 执行一次 durable profile ledger 读取;与 pending 或已上传 hash 相同则完全无副作用。不同或没有 ledger record 才 durable-first 写入并进入上传。
5. 上传:一次初始采集或其他收集完成后,先完成所有待发 profile 的 durable 写入,再按最多 50 条 profile 一个 `contact.profile.observed` frame 发送。保留现有 256 KiB 限制、ACK CAS、future-skew 拒绝、离线 pending 恢复和账号隔离。
6. 诊断:`profile_observed` 只表示实际需要发送的目标会话资料;首次采集可以使用一个安全的聚合诊断,不能为已知/相同 hash 的资料逐条记录 `skipped`
## Out of Scope
- 不增加 24 小时定时或机会式全量刷新。
- 不引入微批 timer、第二条 WebSocket、主动 CRM/HTTP 请求,或扫描/切换页面会话。
- 不修改 Bright 服务端协议、数据库 schema 或 profile 的白名单字段。
## Acceptance Criteria
- [ ] 新安装、认证完成且页面可用时,恰好进行一次当前已加载资料的全量采集;重复 hello、会话切换、Port/Bright 重连均不重复全量采集。
- [ ] 初始采集后的旧会话资料即使再次经过 MAIN → bridge → Service Worker,相同 hash 也只产生一次 IndexedDB 读取;没有写入、`profile_observed` 诊断或 Bright frame。
- [ ] 一个合格的 live 新消息只请求/检查其所属 conversationId 的 profilehistory 不触发资料检查。
- [ ] 相同 8 位 profile hash 不写 ledger、不产生 `profile_observed`、不向 Bright 发送;不同 hash 仍按 durable-first、ACK CAS 正确上传。
- [ ] 初始或多会话待发资料按每帧最多 50 条拆分,同时满足既有 256 KiB 限制;50 以下不被人为拆分。
- [ ] group、缺少登录身份、未知目标会话和账号不匹配继续 fail closed,不推断 identity。
## Key Decision
- live 新消息不区分 `direction`;客户入站和用户发送均触发该会话的一次目标资料检查。相同 hash 是正常无副作用路径,不产生 ledger 写入、诊断或上传。
@@ -0,0 +1,26 @@
{
"id": "onetalk-profile-active-sync",
"name": "onetalk-profile-active-sync",
"title": "收敛 OneTalk 客户资料活跃同步",
"description": "仅在首次认证和会话活跃时采集客户资料,按持久化指纹去重并以 50 条分包上传",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-12",
"completedAt": "2026-09-13",
"branch": "09-12-onetalk-profile-active-sync",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+4 -3
View File
@@ -8,8 +8,8 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 64
- **Last Active**: 2026-09-12
- **Total Sessions**: 65
- **Last Active**: 2026-09-13
<!-- @@@/auto:current-status -->
---
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~1399 | Active |
| `journal-1.md` | ~1433 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 65 | 2026-09-13 | OneTalk profile active sync | `f93255e` | `09-12-onetalk-profile-active-sync` |
| 65 | 2026-09-12 | OneTalk 采集与命令流程边界整理 | `15efc84` | `09-12-onetalk-flow-boundaries` |
| 64 | 2026-09-12 | Harden Mind send liveness | `440df49`, `cc2ee8f` | `09-12-mind-send-liveness` |
| 63 | 2026-09-12 | OneTalk 结构化消息信息采集分类 | `f92f00d`, `31400fc`, `86447cc` | `main` |
+22
View File
@@ -1409,3 +1409,25 @@ Fail-closed plugin lease admission plus Harness heartbeat and HTTP deadline term
### Status
[OK] **Completed**
## Session 65: OneTalk profile active sync
<!-- trellis-session: v=2 fp=2ef84c1ed02f1730 -->
**Date**: 2026-09-13
**Task**: OneTalk profile active sync
**Branch**: `09-12-onetalk-profile-active-sync`
### Summary
收敛首次认证与活跃会话的 OneTalk profile 同步:durable hash 去重、50 条分包、精确 collect 命令与异步 lifecycle fence377 个扩展测试、typecheck、build 通过,Chromium/Bright 实机 smoke deferred。
### Git Commits
| Hash | Message |
|------|---------|
| `f93255e` | feat(onetalk): sync active contact profiles |
### Status
[OK] **Completed**
@@ -13,7 +13,7 @@ import { handleOneTalkSendCommand } from "./send.ts";
export type OneTalkPageCommandDependencies = {
sendObservation: Pick<SendObservationCorrelator, "execute" | "executeImage" | "executeFile">;
observedSink: OneTalkObservedMessageSink;
profileObserver: Pick<OneTalkContactProfileObserver, "snapshot">;
profileObserver: Pick<OneTalkContactProfileObserver, "snapshot" | "collectConversation">;
historyBootstrapProgress: HistoryBootstrapProgressTooltip;
};
@@ -27,7 +27,11 @@ export const createOneTalkPageCommandHandler = (
case "onetalk.send":
return handleOneTalkSendCommand(pageWindow, message, dependencies.sendObservation);
case "onetalk.contact.snapshot":
return handleOneTalkContactProfileCommand(dependencies.profileObserver);
case "onetalk.contact.collect":
return handleOneTalkContactProfileCommand(
dependencies.profileObserver,
message.command,
);
case "onetalk.discover-conversations":
case "onetalk.sync":
case "onetalk.sync.conversation":
@@ -23,6 +23,7 @@ type PageEventBus = {
export type OneTalkContactProfileObserver = {
snapshot: () => OneTalkContactProfile[];
collectConversation: (conversationId: string) => OneTalkContactProfile[];
dispose: () => void;
};
@@ -36,10 +37,6 @@ const unsubscribeFor = (value: unknown): (() => void) | undefined => {
return typeof value === "function" ? (value as () => void) : undefined;
};
const observedProfileKey = (channelAccountId: string, conversationId: string): string => {
return JSON.stringify([channelAccountId, conversationId]);
};
/** 安装独立联系人观察器;初始快照仅由 account-level command 触发。 */
export const installOneTalkContactProfileObserver = (
pageWindow: OneTalkPageWindow,
@@ -49,7 +46,6 @@ export const installOneTalkContactProfileObserver = (
): OneTalkContactProfileObserver => {
let stopped = false;
let observedChannelAccountId: string | undefined;
const seen = new Map<string, string>();
const crmObservations = new Map<string, OneTalkCrmAvatarObservation>();
const now = (): number => Date.now();
const emit = (profiles: OneTalkContactProfile[], channelAccountId: string): void => {
@@ -65,23 +61,11 @@ export const installOneTalkContactProfileObserver = (
};
const clearPageIdentity = (): void => {
observedChannelAccountId = undefined;
seen.clear();
crmObservations.clear();
};
const profilesFromObservationMap = (value: unknown): OneTalkContactProfile[] => {
return profilesFromConversationMap(value, now(), crmObservations);
};
const changedProfiles = (
profiles: OneTalkContactProfile[],
channelAccountId: string,
): OneTalkContactProfile[] => {
return profiles.filter((profile) => {
const key = observedProfileKey(channelAccountId, profile.conversationId);
const previous = seen.get(key);
seen.set(key, profile.profileFingerprint);
return previous !== profile.profileFingerprint;
});
};
const selectedSdkProfile = (conversationId: string): OneTalkContactProfile | null => {
const profiles = profilesFromObservationMap(pageWindow.__conversationListData__);
const matches = profiles.filter((profile) => profile.conversationId === conversationId);
@@ -120,7 +104,7 @@ export const installOneTalkContactProfileObserver = (
}
crmObservations.set(sdkProfile.conversationId, crmObservation);
const nextProfile = contactProfileWithCrmObservation(sdkProfile, crmObservation, now());
emit(changedProfiles([nextProfile], channelAccountId), channelAccountId);
emit([nextProfile], channelAccountId);
};
const consumeUpdates = (value: unknown): void => {
const channelAccountId = readChannelAccountId(pageWindow);
@@ -134,16 +118,12 @@ export const installOneTalkContactProfileObserver = (
observedChannelAccountId !== channelAccountId
) {
observedChannelAccountId = channelAccountId;
seen.clear();
crmObservations.clear();
onDiagnostic?.({ code: "profile_login_identity_changed" });
return;
}
observedChannelAccountId = channelAccountId;
emit(
changedProfiles(profilesFromObservationMap(value), channelAccountId),
channelAccountId,
);
emit(profilesFromObservationMap(value), channelAccountId);
};
const eventBus = eventBusFor(pageWindow);
const unsubscribe = eventBus?.on?.("im-conversation-list:syncData", consumeUpdates);
@@ -171,20 +151,23 @@ export const installOneTalkContactProfileObserver = (
return [];
}
if (observedChannelAccountId !== channelAccountId) {
seen.clear();
crmObservations.clear();
}
observedChannelAccountId = channelAccountId;
const profiles = profilesFromObservationMap(pageWindow.__conversationListData__);
for (const profile of profiles) {
seen.set(
observedProfileKey(channelAccountId, profile.conversationId),
profile.profileFingerprint,
);
}
emit(profiles, channelAccountId);
return profiles;
},
collectConversation: (conversationId) => {
const channelAccountId = readChannelAccountId(pageWindow);
if (stopped || !channelAccountId || conversationId.length === 0) return [];
if (observedChannelAccountId !== channelAccountId) crmObservations.clear();
observedChannelAccountId = channelAccountId;
const profile = selectedSdkProfile(conversationId);
if (!profile) return [];
emit([profile], channelAccountId);
return [profile];
},
dispose: onPageHide,
};
};
@@ -1,13 +1,37 @@
// 处理 Service Worker 请求的联系人资料快照命令
import type { PageCommandResult } from "../../page-bridge/model.ts";
import type { PageCommand, PageCommandResult } from "../../page-bridge/model.ts";
import type { OneTalkContactProfileObserver } from "./entry.ts";
/** 执行联系人资料 account-level snapshot 命令。 */
const isSnapshotCommand = (command: PageCommand): boolean => {
return command.action === "onetalk.contact.snapshot" && Object.keys(command).length === 1;
};
const isCollectConversationCommand = (
command: PageCommand,
): command is PageCommand & {
action: "onetalk.contact.collect";
conversationId: string;
} => {
return (
command.action === "onetalk.contact.collect" &&
Object.keys(command).length === 2 &&
typeof command.conversationId === "string" &&
command.conversationId.length > 0
);
};
/** 执行联系人资料 account-level snapshot 或 targeted collect 命令。 */
export const handleOneTalkContactProfileCommand = (
observer: Pick<OneTalkContactProfileObserver, "snapshot">,
observer: Pick<OneTalkContactProfileObserver, "snapshot" | "collectConversation">,
command: PageCommand,
): PageCommandResult => {
const profiles = observer.snapshot();
const profiles = isSnapshotCommand(command)
? observer.snapshot()
: isCollectConversationCommand(command)
? observer.collectConversation(command.conversationId)
: null;
if (profiles === null) return { status: "rejected_before_send", reason: "invalid_request" };
return {
status: "completed",
profileCount: profiles.length,
@@ -6,6 +6,7 @@ import type {
OneTalkBrightClient,
OneTalkBrightClientOptions,
OneTalkBrightDiagnostic,
OneTalkBrightClientState,
} from "./transport/bright-client.ts";
import { createOneTalkBrightClient } from "./transport/bright-client.ts";
import type { OneTalkExtensionConfig } from "../config.ts";
@@ -61,6 +62,10 @@ export type OneTalkConfiguredSyncSessionOptions = {
onPageDiagnostic?: (event: OneTalkPageDiagnostic) => void;
onEngineDiagnostic?: (event: OneTalkSyncEngineDiagnostic) => void;
onProfileDiagnostic?: (event: OneTalkContactProfileDiagnostic) => void;
onProfileStatus?: (
profile: OneTalkContactProfileCoordinator,
state: OneTalkBrightClientState,
) => void;
now?: () => number;
createRequestId?: (kind: string) => string;
};
@@ -98,6 +103,9 @@ export class OneTalkConfiguredSyncSession {
private readonly onProfileDiagnostic:
| ((event: OneTalkContactProfileDiagnostic) => void)
| undefined;
private readonly onProfileStatus:
| ((profile: OneTalkContactProfileCoordinator, state: OneTalkBrightClientState) => void)
| undefined;
private readonly now: (() => number) | undefined;
private readonly createRequestId: ((kind: string) => string) | undefined;
private store: OneTalkSyncStore | null = null;
@@ -132,6 +140,7 @@ export class OneTalkConfiguredSyncSession {
this.onPageDiagnostic = options.onPageDiagnostic;
this.onEngineDiagnostic = options.onEngineDiagnostic;
this.onProfileDiagnostic = options.onProfileDiagnostic;
this.onProfileStatus = options.onProfileStatus;
this.now = options.now;
this.createRequestId = options.createRequestId;
}
@@ -241,6 +250,7 @@ export class OneTalkConfiguredSyncSession {
? bright.subscribeStatus?.((state) => {
if (currentRevision !== this.revision) return;
activeProfile?.handleStatus(state);
if (activeProfile) this.onProfileStatus?.(activeProfile, state);
activeBuyer?.handleStatus(state);
})
: undefined;
@@ -19,6 +19,8 @@ import {
type OneTalkContactProfileStore,
} from "./storage.ts";
const ONE_TALK_CONTACT_PROFILE_OUTBOUND_BATCH_SIZE = 50;
export type OneTalkContactProfileDiagnostic = {
event: "profile_observed" | "profile_send" | "profile_ack" | "profile_snapshot";
status?:
@@ -45,6 +47,8 @@ export type OneTalkContactProfileDiagnostic = {
export type OneTalkContactProfileCoordinator = {
getChannelAccountId: () => string;
hasProfileRecord: () => Promise<boolean>;
isAuthenticated: () => boolean;
observe: (profiles: OneTalkContactProfile[]) => Promise<void>;
handleFrame: (frame: OneTalkFrame) => void;
handleStatus: (state: OneTalkBrightClientState) => void;
@@ -110,7 +114,11 @@ const profilesFor = (records: OneTalkContactProfileLedgerRecord[]): OneTalkConta
};
const fitsProfileBatch = (scope: OneTalkPluginScope, batch: ProfileBatch): boolean => {
if (batch.records.length > ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE) return false;
if (
batch.records.length > ONE_TALK_CONTACT_PROFILE_OUTBOUND_BATCH_SIZE ||
batch.records.length > ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE
)
return false;
return (
byteLength(
createOneTalkContactProfileObservedFrame(
@@ -203,12 +211,14 @@ export const createOneTalkContactProfileCoordinator = (options: {
const createRequestId =
options.createRequestId ?? ((kind: string) => `profile-${kind}-${++requestSequence}`);
const requests = new Map<string, RequestEntry[]>();
const profileWrites = new Map<string, Promise<OneTalkContactProfileLedgerRecord | null>>();
const writer = createOneTalkContactProfileFrameWriter({
scope: options.scope,
send: (frame) => options.bright.send(frame),
createRequestId,
});
let disposed = false;
let authenticated = false;
let flushing: Promise<void> | null = null;
let followUpFlushRequested = false;
const reportError = (error: unknown): void => {
@@ -315,22 +325,54 @@ export const createOneTalkContactProfileCoordinator = (options: {
}
validProfiles.push(profile);
}
const persistProfile = (
profile: OneTalkContactProfile,
): Promise<OneTalkContactProfileLedgerRecord | null> => {
const key = contactProfileKey(options.scope.channelAccountId, profile.conversationId);
const previous = profileWrites.get(key) ?? Promise.resolve(null);
const operation = previous.then(async () => {
const existing = await options.store.getProfile(
options.scope.channelAccountId,
profile.conversationId,
);
if (disposed) return null;
if (
existing?.pending?.fingerprint === profile.profileFingerprint ||
existing?.lastUploadedFingerprint === profile.profileFingerprint
) {
return null;
}
const record = await options.store.putPendingProfile(
options.scope.channelAccountId,
profile,
);
return record.pending?.fingerprint === profile.profileFingerprint &&
record.pending.observedAtMs === profile.observedAtMs
? record
: null;
});
let tracked: Promise<null> | null = null;
const clearTracker = (): null => {
if (tracked && profileWrites.get(key) === tracked) profileWrites.delete(key);
return null;
};
tracked = operation.then(clearTracker, clearTracker);
profileWrites.set(key, tracked);
return operation;
};
let pendingCount = 0;
for (const profile of stableDedupeProfiles(options.scope.channelAccountId, validProfiles)) {
const record = await options.store.putPendingProfile(
options.scope.channelAccountId,
profile,
);
const record = await persistProfile(profile);
if (!record || disposed) continue;
pendingCount += 1;
emit(options.onDiagnostic, {
event: "profile_observed",
status:
record.pending?.fingerprint === profile.profileFingerprint
? "pending"
: "skipped",
status: "pending",
profileCount: 1,
fieldNames: PROFILE_FIELD_NAMES,
});
}
await flush();
if (pendingCount > 0) await flush();
};
const handleFrame = (frame: OneTalkFrame): void => {
@@ -421,6 +463,7 @@ export const createOneTalkContactProfileCoordinator = (options: {
};
const handleStatus = (state: OneTalkBrightClientState): void => {
authenticated = state.status === "authenticated";
if (state.status !== "authenticated") {
requests.clear();
followUpFlushRequested = false;
@@ -431,6 +474,8 @@ export const createOneTalkContactProfileCoordinator = (options: {
return {
getChannelAccountId: () => options.scope.channelAccountId,
hasProfileRecord: () => options.store.hasProfileRecord(options.scope.channelAccountId),
isAuthenticated: () => authenticated,
observe,
handleFrame,
handleStatus,
@@ -441,6 +486,8 @@ export const createOneTalkContactProfileCoordinator = (options: {
},
dispose: () => {
disposed = true;
authenticated = false;
profileWrites.clear();
requests.clear();
followUpFlushRequested = false;
},
@@ -34,6 +34,13 @@ type PageLifecycleToken = {
pageEpoch: number;
};
type ProfileSetup = {
channelAccountId: string;
configurationEpoch: number;
profile: OneTalkContactProfileCoordinator;
state: "checking" | "requested";
};
const diagnosticForProfileSnapshot = (
status: "failed" | "rejected",
code: "snapshot_unavailable" | "snapshot_failed",
@@ -49,6 +56,8 @@ export class OneTalkPageRuntimeHost {
private readonly options: OneTalkPageRuntimeHostOptions;
private lastPageIdentity: OneTalkPageIdentity | null = null;
private profileSnapshotRequestSequence = 0;
private profileSetup: ProfileSetup | null = null;
private readonly liveProfileCollections = new Set<string>();
private connectionEpoch = 0;
private pageEpoch = 0;
private disconnectedChannelAccountId: string | null = null;
@@ -96,38 +105,105 @@ export class OneTalkPageRuntimeHost {
}
}
private requestProfileSnapshot(
channelAccountId: string,
token: PageLifecycleToken,
identity: OneTalkPageIdentity,
engine: OneTalkSyncEngine | null,
profile: OneTalkContactProfileCoordinator | null,
): void {
if (!this.isCurrentToken(token, identity, engine, profile)) return;
void this.runtime
.routePageCommand({
channelAccountId,
requestId: `profile-snapshot-${++this.profileSnapshotRequestSequence}`,
command: { action: "onetalk.contact.snapshot" },
})
.then((result) => {
if (!this.isCurrentToken(token, identity, engine, profile)) return;
private isCurrentProfileSetup(setup: ProfileSetup): boolean {
return (
this.profileSetup === setup &&
setup.configurationEpoch === this.configurationEpoch() &&
this.lastPageIdentity?.channelAccountId === setup.channelAccountId &&
this.options.getActiveProfileCoordinator?.() === setup.profile &&
(this.options.getActiveChannelAccountId?.() ?? setup.channelAccountId) ===
setup.channelAccountId
);
}
private requestInitialProfileSnapshot(profile: OneTalkContactProfileCoordinator): void {
const identity = this.lastPageIdentity;
if (
!identity ||
!profile.isAuthenticated() ||
profile.getChannelAccountId() !== identity.channelAccountId
)
return;
const existing = this.profileSetup;
if (
existing &&
existing.channelAccountId === identity.channelAccountId &&
existing.configurationEpoch === this.configurationEpoch() &&
existing.profile === profile &&
this.isCurrentProfileSetup(existing)
)
return;
const setup: ProfileSetup = {
channelAccountId: identity.channelAccountId,
configurationEpoch: this.configurationEpoch(),
profile,
state: "checking",
};
this.profileSetup = setup;
void profile
.hasProfileRecord()
.then(async (hasRecord) => {
if (!this.isCurrentProfileSetup(setup) || !setup.profile.isAuthenticated()) return;
if (hasRecord) {
setup.state = "requested";
return;
}
const result = await this.runtime.routePageCommand({
channelAccountId: setup.channelAccountId,
requestId: `profile-snapshot-${++this.profileSnapshotRequestSequence}`,
command: { action: "onetalk.contact.snapshot" },
});
if (!this.isCurrentProfileSetup(setup)) return;
if (
result.status === "rejected_before_send" ||
result.status === "delivery_unknown"
) {
this.profileSetup = null;
this.emitDiagnostic(
diagnosticForProfileSnapshot("rejected", "snapshot_unavailable"),
);
return;
}
setup.state = "requested";
})
.catch(() => {
if (this.isCurrentToken(token, identity, engine, profile)) {
this.emitDiagnostic(diagnosticForProfileSnapshot("failed", "snapshot_failed"));
}
if (!this.isCurrentProfileSetup(setup)) return;
this.profileSetup = null;
this.emitDiagnostic(diagnosticForProfileSnapshot("failed", "snapshot_failed"));
});
}
private requestLiveProfileCollection(channelAccountId: string, conversationId: string): void {
const profile = this.options.getActiveProfileCoordinator?.() ?? null;
if (!profile || profile.getChannelAccountId() !== channelAccountId) return;
const key = JSON.stringify([channelAccountId, conversationId]);
if (this.liveProfileCollections.has(key)) return;
this.liveProfileCollections.add(key);
void this.runtime
.routePageCommand({
channelAccountId,
requestId: `profile-collect-${++this.profileSnapshotRequestSequence}`,
command: { action: "onetalk.contact.collect", conversationId },
})
.catch((error) => this.options.onError(error))
.finally(() => this.liveProfileCollections.delete(key));
}
private requestLiveProfileCollections(
message: Parameters<
NonNullable<OneTalkServiceWorkerRuntimeOptions["persistPageObservation"]>
>[0],
channelAccountId: string,
): void {
for (const conversationId of new Set(
message.batch
.filter((entry) => entry.messageType === "new")
.map((entry) => entry.conversationId),
)) {
this.requestLiveProfileCollection(channelAccountId, conversationId);
}
}
private resumeBuyerDelivery(
token: PageLifecycleToken,
identity: OneTalkPageIdentity,
@@ -168,7 +244,7 @@ export class OneTalkPageRuntimeHost {
return;
this.replayConnectionStatus(channelAccountId);
this.resumeBuyerDelivery(token, identity, engine, profile, buyer);
this.requestProfileSnapshot(channelAccountId, token, identity, engine, profile);
if (profile) this.requestInitialProfileSnapshot(profile);
await engine?.handlePageReady(channelAccountId, conversationId);
if (!this.isCurrentToken(token, identity, engine, profile)) return;
await profile?.handlePageReady();
@@ -196,6 +272,7 @@ export class OneTalkPageRuntimeHost {
const engine = options.getActiveEngine();
if (!engine) return;
await engine.handlePageObservation(message, channelAccountId);
this.requestLiveProfileCollections(message, channelAccountId);
void sender;
},
persistPageProfileObservation: async (
@@ -225,6 +302,7 @@ export class OneTalkPageRuntimeHost {
this.connectionEpoch += 1;
this.pageEpoch += 1;
this.lastPageIdentity = null;
this.liveProfileCollections.clear();
options.getActiveEngine()?.handlePageDisconnected();
options.getActiveProfileCoordinator?.()?.handlePageDisconnected();
options.getActiveBuyerFactCoordinator?.()?.handlePageDisconnected();
@@ -268,6 +346,16 @@ export class OneTalkPageRuntimeHost {
this.runtime.publishConnectionStatus(previousChannelAccountId, false);
}
public handleProfileStatus(
profile: OneTalkContactProfileCoordinator,
status: OneTalkBrightConnectionStatus,
): void {
if (status !== "authenticated" && this.profileSetup?.profile === profile) {
this.profileSetup = null;
}
if (status === "authenticated") this.requestInitialProfileSnapshot(profile);
}
public async replayTo(
engine: OneTalkSyncEngine,
profileCoordinator?: OneTalkContactProfileCoordinator | null,
@@ -289,7 +377,7 @@ export class OneTalkPageRuntimeHost {
)
return;
this.resumeBuyerDelivery(token, identity, engine, profile, buyer);
this.requestProfileSnapshot(identity.channelAccountId, token, identity, engine, profile);
if (profile) this.requestInitialProfileSnapshot(profile);
await engine.handlePageReady(identity.channelAccountId, identity.conversationId);
if (!this.isCurrentToken(token, identity, engine, profile)) return;
await profile?.handlePageReady();
@@ -327,6 +327,7 @@ const dispatchPageObservation = (
const dispatchPageProfileObservation = (
options: OneTalkServiceWorkerRuntimeOptions,
connections: Map<string, PageConnection>,
connection: PageConnection,
message: OneTalkPageProfileObservedMessage,
): void => {
@@ -343,6 +344,8 @@ const dispatchPageProfileObservation = (
}
const onProfileMessage = options.onPageProfileMessage;
if (!onProfileMessage) return;
const conversationId = connection.conversationId;
const conversationSelection = connection.conversationSelection;
try {
const persist = options.persistPageProfileObservation;
const notify = (): void | Promise<void> => onProfileMessage(message, connection.sender);
@@ -353,7 +356,16 @@ const dispatchPageProfileObservation = (
return;
}
void Promise.resolve(persist(message, connection.sender, channelAccountId))
.then(notify)
.then(() => {
if (
!isCurrentConnection(connections, connection) ||
connection.channelAccountId !== channelAccountId ||
connection.conversationId !== conversationId ||
connection.conversationSelection !== conversationSelection
)
return;
return notify();
})
.catch((error) => {
reportRuntimeError(options.onError, error);
});
@@ -448,7 +460,7 @@ const createPageMessageHandler = (
dispatchPageObservation(options, connection, message);
return;
case "onetalk.page.profile-observed":
dispatchPageProfileObservation(options, connection, message);
dispatchPageProfileObservation(options, connections, connection, message);
return;
case "onetalk.page.buyer-facts-observed":
dispatchPageBuyerFactsObservation(options, connection, message);
@@ -491,7 +503,17 @@ const isAccountLevelCommand = (command: PageCommand): boolean => {
return (
command.action === "onetalk.sync" ||
command.action === "onetalk.discover-conversations" ||
command.action === "onetalk.contact.snapshot"
command.action === "onetalk.contact.snapshot" ||
command.action === "onetalk.contact.collect"
);
};
const isProfileCollectCommand = (command: PageCommand): boolean => {
return (
command.action === "onetalk.contact.collect" &&
Object.keys(command).length === 2 &&
typeof command.conversationId === "string" &&
command.conversationId.length > 0
);
};
@@ -624,6 +646,12 @@ export const createOneTalkServiceWorkerRuntime = (
});
return rejectedBeforeSend("invalid_request");
}
if (
route.command.action === "onetalk.contact.collect" &&
!isProfileCollectCommand(route.command)
) {
return rejectedBeforeSend("invalid_request");
}
let connection: PageConnection | null;
if (route.conversationId === undefined) {
@@ -183,6 +183,7 @@ export type OneTalkContactProfileStore = {
listPendingProfiles: (
channelAccountId?: string,
) => Promise<OneTalkContactProfileLedgerRecord[]>;
hasProfileRecord: (channelAccountId: string) => Promise<boolean>;
putPendingProfile: (
channelAccountId: string,
profile: OneTalkContactProfile,
@@ -815,6 +816,15 @@ export const createOneTalkContactProfileStore = (
);
};
const hasProfileRecord = async (channelAccountId: string): Promise<boolean> => {
const database = await getDatabase();
const records = await readAll<OneTalkContactProfileLedgerRecord>(
database,
ONE_TALK_CONTACT_PROFILE_STORE_NAME,
);
return records.some((record) => record.channelAccountId === channelAccountId);
};
const putPendingProfile = async (
channelAccountId: string,
profile: OneTalkContactProfile,
@@ -954,6 +964,7 @@ export const createOneTalkContactProfileStore = (
return {
getProfile,
listPendingProfiles,
hasProfileRecord,
putPendingProfile,
markProfileUploaded,
discardPendingProfile,
@@ -156,6 +156,7 @@ export const createOneTalkServiceWorkerSyncController = (
onPageDiagnostic: options.onPageDiagnostic,
onEngineDiagnostic: options.onEngineDiagnostic,
onProfileDiagnostic: options.onProfileDiagnostic,
onProfileStatus: (profile, state) => pageHost.handleProfileStatus(profile, state.status),
now: options.now,
createRequestId: options.createRequestId,
});
@@ -67,9 +67,6 @@ export const createOneTalkServiceWorkerSyncRuntime = (
onDiagnostic: options.onProfileDiagnostic,
})
: undefined;
const unsubscribeProfileStatus = options.bright.subscribeStatus?.((state) =>
profile?.handleStatus(state),
);
pageHost = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => profile ?? null,
@@ -77,6 +74,10 @@ export const createOneTalkServiceWorkerSyncRuntime = (
onProfileDiagnostic: options.onProfileDiagnostic,
onError: options.onError ?? (() => undefined),
});
const unsubscribeProfileStatus = options.bright.subscribeStatus?.((state) => {
profile?.handleStatus(state);
if (profile) pageHost.handleProfileStatus(profile, state.status);
});
let requestSequence = 0;
const createRequestId =
options.createRequestId ?? ((kind: string) => `runtime-${kind}-${++requestSequence}`);
@@ -66,8 +66,8 @@ class ProfileStore {
this.records = new Map();
}
async getProfile(channelAccountId, aliId) {
return this.records.get(JSON.stringify([channelAccountId, aliId])) ?? null;
async getProfile(channelAccountId, conversationId) {
return this.records.get(JSON.stringify([channelAccountId, conversationId])) ?? null;
}
async listPendingProfiles(channelAccountId) {
@@ -78,8 +78,14 @@ class ProfileStore {
);
}
async hasProfileRecord(channelAccountId) {
return [...this.records.values()].some(
(record) => record.channelAccountId === channelAccountId,
);
}
async putPendingProfile(channelAccountId, profile) {
const key = JSON.stringify([channelAccountId, profile.aliId]);
const key = JSON.stringify([channelAccountId, profile.conversationId]);
const current = this.records.get(key);
if (current?.lastUploadedFingerprint === profile.profileFingerprint && !current.pending) {
return current;
@@ -87,6 +93,7 @@ class ProfileStore {
const record = {
key,
channelAccountId,
conversationId: profile.conversationId,
aliId: profile.aliId,
lastUploadedFingerprint: current?.lastUploadedFingerprint ?? null,
pending: {
@@ -100,8 +107,8 @@ class ProfileStore {
return record;
}
async markProfileUploaded({ channelAccountId, aliId, fingerprint, uploadedAt }) {
const key = JSON.stringify([channelAccountId, aliId]);
async markProfileUploaded({ channelAccountId, conversationId, fingerprint, uploadedAt }) {
const key = JSON.stringify([channelAccountId, conversationId]);
const current = this.records.get(key);
if (current?.pending?.fingerprint !== fingerprint) return false;
this.records.set(key, {
@@ -113,6 +120,30 @@ class ProfileStore {
});
return true;
}
async discardPendingProfile({
channelAccountId,
conversationId,
fingerprint,
observedAtMs,
rejectedAt,
}) {
const key = JSON.stringify([channelAccountId, conversationId]);
const current = this.records.get(key);
if (
current?.pending?.fingerprint !== fingerprint ||
current.pending.observedAtMs !== observedAtMs
)
return false;
this.records.set(key, {
...current,
pending: undefined,
lastRejectedFingerprint: fingerprint,
lastRejectedObservedAtMs: observedAtMs,
updatedAt: rejectedAt,
});
return true;
}
}
const profile = {
@@ -325,14 +356,16 @@ test("creates an account-scoped profile coordinator that carries avatarUrl", asy
{ profiles: [profile], requestId: "profile-batch-1" },
]);
assert.equal(
(await profileStore.getProfile("account-1", profile.aliId)).pending.profile.avatarUrl,
(await profileStore.getProfile("account-1", profile.conversationId)).pending.profile
.avatarUrl,
profile.avatarUrl,
);
await session.configure({ ...config, channelAccountId: "account-2", deviceId: "device-2" });
assert.equal(session.getActive().profile.getChannelAccountId(), "account-2");
assert.equal(
(await profileStore.getProfile("account-1", profile.aliId)).pending.profile.avatarUrl,
(await profileStore.getProfile("account-1", profile.conversationId)).pending.profile
.avatarUrl,
profile.avatarUrl,
);
});
@@ -32,9 +32,11 @@ const profile = (
observationStatus: "confirmed",
});
const createFixture = ({ holdFirstList = false, onDiagnostic } = {}) => {
const createFixture = ({ holdFirstList = false, onDiagnostic, getProfile } = {}) => {
const records = new Map();
const sent = [];
let getCount = 0;
let putCount = 0;
let listCount = 0;
let listStarted;
let releaseList;
@@ -42,6 +44,13 @@ const createFixture = ({ holdFirstList = false, onDiagnostic } = {}) => {
listStarted = resolve;
});
const store = {
getProfile: async (channelAccountId, conversationId) => {
getCount += 1;
if (getProfile) return getProfile(channelAccountId, conversationId);
return records.get(JSON.stringify([channelAccountId, conversationId])) ?? null;
},
hasProfileRecord: async (channelAccountId) =>
[...records.values()].some((record) => record.channelAccountId === channelAccountId),
listPendingProfiles: async () => {
const snapshot = [...records.values()].filter((record) => record.pending);
listCount += 1;
@@ -53,6 +62,7 @@ const createFixture = ({ holdFirstList = false, onDiagnostic } = {}) => {
return snapshot;
},
putPendingProfile: async (channelAccountId, next) => {
putCount += 1;
const key = JSON.stringify([channelAccountId, next.conversationId]);
const current = records.get(key);
if (
@@ -144,7 +154,15 @@ const createFixture = ({ holdFirstList = false, onDiagnostic } = {}) => {
now: () => 1_700_000_000_100,
onDiagnostic,
});
return { coordinator, records, sent, firstListStarted, releaseList: () => releaseList?.() };
return {
coordinator,
records,
sent,
getCount: () => getCount,
putCount: () => putCount,
firstListStarted,
releaseList: () => releaseList?.(),
};
};
test("writes pending before send, replaces pending avatar, and ignores stale ACK", async () => {
@@ -216,6 +234,91 @@ test("writes pending before send, replaces pending avatar, and ignores stale ACK
assert.equal(fixture.sent.length, 2);
});
test("silently short-circuits an uploaded fingerprint after one durable read", async () => {
const diagnostics = [];
const fixture = createFixture({ onDiagnostic: (event) => diagnostics.push(event) });
const first = profile("First", "v1-first");
await fixture.coordinator.observe([first]);
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
protocolVersion: 2,
connectionType: "plugin",
requestId: "profile-batch-1",
scope,
payload: { status: "delivered", profileCount: 1 },
});
await new Promise((resolve) => setImmediate(resolve));
const getCount = fixture.getCount();
const putCount = fixture.putCount();
const sentCount = fixture.sent.length;
const observedCount = diagnostics.filter((event) => event.event === "profile_observed").length;
await fixture.coordinator.observe([first]);
assert.equal(fixture.getCount(), getCount + 1);
assert.equal(fixture.putCount(), putCount);
assert.equal(fixture.sent.length, sentCount);
assert.equal(
diagnostics.filter((event) => event.event === "profile_observed").length,
observedCount,
);
});
test("does not write the same fingerprint twice when profile observations overlap", async () => {
const fixture = createFixture();
const first = profile("First", "v1-first");
await Promise.all([fixture.coordinator.observe([first]), fixture.coordinator.observe([first])]);
assert.equal(fixture.getCount(), 2);
assert.equal(fixture.putCount(), 1);
assert.equal(fixture.sent.length, 1);
});
test("does not leave a rejected profile-write tracker promise unhandled", async () => {
const fixture = createFixture({
getProfile: async () => {
throw new Error("profile read failed");
},
});
const unhandled = [];
const onUnhandledRejection = (reason) => unhandled.push(reason);
process.on("unhandledRejection", onUnhandledRejection);
try {
await assert.rejects(fixture.coordinator.observe([profile("First", "v1-first")]), {
message: "profile read failed",
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(unhandled, []);
} finally {
process.off("unhandledRejection", onUnhandledRejection);
}
});
test("does not write a profile after the coordinator is disposed during its durable read", async () => {
let releaseProfileRead;
const profileRead = new Promise((resolve) => {
releaseProfileRead = resolve;
});
const diagnostics = [];
const fixture = createFixture({
getProfile: async () => profileRead,
onDiagnostic: (event) => diagnostics.push(event),
});
const observation = fixture.coordinator.observe([profile("First", "v1-first")]);
await Promise.resolve();
fixture.coordinator.dispose();
releaseProfileRead(null);
await observation;
assert.equal(fixture.putCount(), 0);
assert.equal(fixture.sent.length, 0);
assert.deepEqual(diagnostics, []);
});
test("keeps the first maximum batch observation and isolates shared aliIds by conversation", async () => {
const fixture = createFixture();
const older = profile("Older", "v1-older", "shared-ali", null, "conversation-a", 100);
@@ -274,14 +377,40 @@ test("keeps the first maximum batch observation and isolates shared aliIds by co
assert.equal(otherRecord.pending, undefined);
});
test("does not let an earlier ACK settle a newer same-fingerprint observation", async () => {
test("caps extension outbound profile frames at fifty records", async () => {
const profiles = (count) =>
Array.from({ length: count }, (_, index) =>
profile(
`Profile ${index}`,
`v1-${count}-${index}`,
`ali-${count}-${index}`,
null,
`conversation-${count}-${index}`,
),
);
const fifty = createFixture();
await fifty.coordinator.observe(profiles(50));
assert.deepEqual(
fifty.sent.map((entry) => entry.profiles.length),
[50],
);
const fiftyOne = createFixture();
await fiftyOne.coordinator.observe(profiles(51));
assert.deepEqual(
fiftyOne.sent.map((entry) => entry.profiles.length),
[50, 1],
);
});
test("makes a newer same-fingerprint observation a read-only no-op", async () => {
const fixture = createFixture();
const earlier = profile("Same customer", "v1-same", "shared-ali", null, "conversation-1", 100);
const later = profile("Same customer", "v1-same", "shared-ali", null, "conversation-1", 101);
await fixture.coordinator.observe([earlier]);
await fixture.coordinator.observe([later]);
assert.equal(fixture.sent.length, 2);
assert.equal(fixture.sent.length, 1);
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
@@ -292,28 +421,13 @@ test("does not let an earlier ACK settle a newer same-fingerprint observation",
payload: { status: "delivered", profileCount: 1 },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
fixture.records.get(JSON.stringify([scope.channelAccountId, later.conversationId])).pending
.observedAtMs,
later.observedAtMs,
);
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
protocolVersion: 2,
connectionType: "plugin",
requestId: "profile-batch-2",
scope,
payload: { status: "delivered", profileCount: 1 },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
fixture.records.get(JSON.stringify([scope.channelAccountId, later.conversationId])).pending,
undefined,
);
});
test("does not let a future error discard a newer same-fingerprint observation", async () => {
test("does not reopen a pending profile for a newer same fingerprint", async () => {
const fixture = createFixture();
const earlier = profile("Same customer", "v1-same", "shared-ali", null, "conversation-1", 100);
const later = profile("Same customer", "v1-same", "shared-ali", null, "conversation-1", 101);
@@ -333,23 +447,8 @@ test("does not let a future error discard a newer same-fingerprint observation",
const record = fixture.records.get(
JSON.stringify([scope.channelAccountId, later.conversationId]),
);
assert.equal(record.pending.observedAtMs, later.observedAtMs);
assert.equal(record.lastUploadedFingerprint, null);
assert.equal(record.lastRejectedFingerprint, undefined);
fixture.coordinator.handleFrame({
type: "contact.profile.ack",
protocolVersion: 2,
connectionType: "plugin",
requestId: "profile-batch-2",
scope,
payload: { status: "delivered", profileCount: 1 },
});
await new Promise((resolve) => setImmediate(resolve));
assert.equal(
fixture.records.get(JSON.stringify([scope.channelAccountId, later.conversationId])).pending,
undefined,
);
assert.equal(record.pending, undefined);
assert.equal(record.lastRejectedFingerprint, earlier.profileFingerprint);
});
test("discards only the request-scoped future batch without marking it uploaded", async () => {
@@ -603,7 +702,7 @@ test("sends a chunk update after a multi-chunk flush has already started", async
assert.deepEqual(
fixture.sent.map((entry) => entry.profiles.length),
[100, 1, 1],
[50, 50, 1, 1],
);
assert.equal(fixture.sent.at(-1).profiles[0].profileFingerprint, "v2-50");
});
@@ -71,7 +71,7 @@ test("constructs a whitelist profile without leaking sensitive row fields", () =
assert.equal(JSON.stringify(profile).includes("secret"), false);
});
test("emits snapshot, changed syncData, skips groups and unsubscribes on pagehide", () => {
test("emits snapshot and every direct syncData observation, then unsubscribes on pagehide", () => {
const fixture = createPage({ first: row() });
const batches = [];
const observer = installOneTalkContactProfileObserver(fixture.page, (profiles) => {
@@ -84,19 +84,19 @@ test("emits snapshot, changed syncData, skips groups and unsubscribes on pagehid
assert.equal(batches[0][0].aliId, "2208314000798");
fixture.page.emitSyncData({ first: row() });
assert.equal(batches.length, 1);
assert.equal(batches.length, 2);
fixture.page.emitSyncData({
first: row("Changed Name"),
group: { ...row(), cid: "group-1", isGroup: true },
incomplete: { cid: "missing-ali", name: "ignored" },
});
assert.equal(batches.length, 2);
assert.equal(batches[1][0].name, "Changed Name");
assert.equal(batches.length, 3);
assert.equal(batches[2][0].name, "Changed Name");
fixture.page.pagehide();
assert.equal(fixture.isUnsubscribed(), true);
fixture.page.emitSyncData({ first: row("After Hide") });
assert.equal(batches.length, 2);
assert.equal(batches.length, 3);
});
test("requires logged-in identity for a snapshot and never uses activeAccountId", () => {
@@ -190,7 +190,7 @@ test("uses the SDK avatar priority, skips invalid candidates, and fingerprints a
);
});
test("snapshot and syncData share the same SDK extraction and avatar fingerprint", () => {
test("snapshot and syncData share the same SDK extraction without MAIN-side profile dedupe", () => {
const fixture = createPage({ first: row() });
const batches = [];
const observer = installOneTalkContactProfileObserver(fixture.page, (profiles) => {
@@ -201,10 +201,32 @@ test("snapshot and syncData share the same SDK extraction and avatar fingerprint
fixture.page.emitSyncData({ first: row() });
assert.equal(snapshot[0].avatarUrl, batches[0][0].avatarUrl);
assert.equal(snapshot[0].profileFingerprint, batches[0][0].profileFingerprint);
assert.equal(batches.length, 1);
fixture.page.emitSyncData({ first: row("Heena Liu", "https://cdn.example.test/changed.jpg") });
assert.equal(batches.length, 2);
assert.equal(batches[1][0].avatarUrl, "https://cdn.example.test/changed.jpg");
assert.deepEqual(batches[1], batches[0]);
fixture.page.emitSyncData({ first: row("Heena Liu", "https://cdn.example.test/changed.jpg") });
assert.equal(batches.length, 3);
assert.equal(batches[2][0].avatarUrl, "https://cdn.example.test/changed.jpg");
});
test("collects only a requested direct conversation from the loaded SDK map", () => {
const fixture = createPage({
first: row(),
second: { ...row("Other"), cid: "conversation-2" },
});
const batches = [];
const observer = installOneTalkContactProfileObserver(fixture.page, (profiles) => {
batches.push(profiles);
});
assert.deepEqual(
observer.collectConversation("conversation-2").map((profile) => profile.conversationId),
["conversation-2"],
);
assert.deepEqual(observer.collectConversation("missing"), []);
assert.deepEqual(
batches.map((profiles) => profiles.map((profile) => profile.conversationId)),
[["conversation-2"]],
);
});
const crmSeam = (capture) => ({
@@ -229,6 +229,14 @@ test("resets legacy OneTalk stores before v7 while opening the current database
}
});
test("reports whether an account has any durable profile ledger record", async () => {
const store = createOneTalkContactProfileStore(new Factory(), () => 100);
assert.equal(await store.hasProfileRecord("account-1"), false);
await store.putPendingProfile("account-1", profile());
assert.equal(await store.hasProfileRecord("account-1"), true);
assert.equal(await store.hasProfileRecord("account-2"), false);
});
test("does not return true before the readwrite transaction commits", async () => {
const factory = new Factory();
const store = createOneTalkContactProfileStore(factory, () => 100);
@@ -85,6 +85,7 @@ test("connects one publisher batch to the real send correlator and one page sink
test("routes a profile snapshot once and rejects an unknown page action", async () => {
let snapshots = 0;
const collected = [];
const handler = createOneTalkPageCommandHandler(
{},
{
@@ -95,6 +96,10 @@ test("routes a profile snapshot once and rejects an unknown page action", async
snapshots += 1;
return [{ conversationId: "conversation-1" }];
},
collectConversation: (conversationId) => {
collected.push(conversationId);
return [{ conversationId }];
},
},
historyBootstrapProgress: {},
},
@@ -105,6 +110,22 @@ test("routes a profile snapshot once and rejects an unknown page action", async
profileCount: 1,
});
assert.equal(snapshots, 1);
assert.deepEqual(
await handler({ command: { action: "onetalk.contact.snapshot", extra: true } }),
{ status: "rejected_before_send", reason: "invalid_request" },
);
assert.equal(snapshots, 1);
assert.deepEqual(
await handler({
command: { action: "onetalk.contact.collect", conversationId: "conversation-2" },
}),
{ status: "completed", profileCount: 1 },
);
assert.deepEqual(collected, ["conversation-2"]);
assert.deepEqual(
await handler({ command: { action: "onetalk.contact.collect", conversationId: "" } }),
{ status: "rejected_before_send", reason: "invalid_request" },
);
assert.deepEqual(await handler({ command: { action: "unexpected.action" } }), {
status: "rejected_before_send",
reason: "invalid_request",
@@ -46,6 +46,14 @@ const profile = {
observationStatus: "confirmed",
};
const profileCoordinator = (channelAccountId, hasProfileRecord = false) => ({
getChannelAccountId: () => channelAccountId,
hasProfileRecord: async () => hasProfileRecord,
isAuthenticated: () => true,
handlePageReady: async () => {},
handlePageDisconnected: () => {},
});
class FakePort {
constructor(sender, name = ONE_TALK_PAGE_PORT_NAME) {
this.name = name;
@@ -259,8 +267,10 @@ test("rejects the removed buyer collection command without posting it to the pag
test("requests only one profile snapshot for repeated hello from the same page identity", async () => {
const engine = { handlePageReady: async () => {} };
const activeProfile = profileCoordinator("account-unique");
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => activeProfile,
getActiveChannelAccountId: () => "account-unique",
onError: () => {},
});
@@ -287,6 +297,7 @@ test("requests a new profile snapshot when the page identity changes", async ()
const engine = { handlePageReady: async () => {} };
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => null,
onError: () => {},
});
const port = new FakePort(pageSender(31));
@@ -297,7 +308,7 @@ test("requests a new profile snapshot when the page identity changes", async ()
assert.deepEqual(
port.posted.map((message) => message.command?.action),
["onetalk.contact.snapshot", "onetalk.contact.snapshot"],
[],
);
});
@@ -323,6 +334,36 @@ test("does not pass an old page observation to a replacement-account engine", as
assert.deepEqual(handled, []);
});
test("routes only live message conversations to targeted profile collection", async () => {
const engine = {
handlePageObservation: async () => {},
handlePageReady: async () => {},
};
const activeProfile = profileCoordinator("account-live", true);
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => activeProfile,
getActiveChannelAccountId: () => "account-live",
onError: () => {},
});
const port = new FakePort(pageSender(39));
host.runtime.handleConnect(port);
port.dispatchMessage(createOneTalkPageHelloMessage("account-live"));
port.dispatchMessage(
createOneTalkPageObservedMessage([
observedMessage,
{ ...observedMessage, messageId: "history-1", messageType: "history" },
{ ...observedMessage, messageId: "live-duplicate" },
]),
);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
port.posted.map((message) => message.command),
[{ action: "onetalk.contact.collect", conversationId: "conversation-1" }],
);
});
test("durably gates profile messages and routes the account-level profile snapshot command", async () => {
const events = [];
const runtime = createOneTalkServiceWorkerRuntime({
@@ -377,7 +418,7 @@ test("rejects a profile update whose page identity does not match the last hello
assert.deepEqual(events, []);
});
test("keeps profile observations on the current page account across an identity change", async () => {
test("notifies only the current page identity after profile persistence", async () => {
const persisted = [];
const handled = [];
const runtime = createOneTalkServiceWorkerRuntime({
@@ -404,7 +445,35 @@ test("keeps profile observations on the current page account across an identity
["account-a", profile.avatarUrl],
["account-b", null],
]);
assert.deepEqual(handled, ["account-a", "account-b"]);
assert.deepEqual(handled, ["account-b"]);
});
test("does not notify the profile handler after the page identity changes during persistence", async () => {
let releasePersistence;
const persistenceStarted = new Promise((resolve) => {
releasePersistence = resolve;
});
const persisted = [];
const handled = [];
const runtime = createOneTalkServiceWorkerRuntime({
persistPageProfileObservation: async (message, _sender, channelAccountId) => {
persisted.push([channelAccountId, message.profiles[0].avatarUrl]);
await persistenceStarted;
},
onPageProfileMessage: (message) => handled.push(message.channelAccountId),
onPageMessage: () => undefined,
});
const port = new FakePort(pageSender(38));
connectPage(runtime, port, "account-a", undefined);
port.dispatchMessage(createOneTalkPageProfileObservedMessage([profile], "account-a"));
await Promise.resolve();
port.dispatchMessage(createOneTalkPageHelloMessage("account-b", undefined));
releasePersistence();
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(persisted, [["account-a", profile.avatarUrl]]);
assert.deepEqual(handled, []);
});
test("profile persistence failure does not block the independent message observer", async () => {
@@ -395,9 +395,16 @@ test("replay only resumes durable buyer delivery after a page hello", async () =
test("reports snapshot route failure without blocking page readiness", async () => {
const diagnostics = [];
const engine = { handlePageReady: async () => {} };
const activeProfile = {
getChannelAccountId: () => "account-failure",
hasProfileRecord: async () => false,
isAuthenticated: () => true,
handlePageReady: async () => {},
handlePageDisconnected: () => {},
};
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => engine,
getActiveProfileCoordinator: () => null,
getActiveProfileCoordinator: () => activeProfile,
getActiveChannelAccountId: () => "account-failure",
getConfigurationEpoch: () => 1,
onProfileDiagnostic: (event) => diagnostics.push(event),
@@ -421,6 +428,75 @@ test("reports snapshot route failure without blocking page readiness", async ()
);
});
test("standalone sync runtime requests the initial snapshot when Bright authenticates after page hello", async () => {
const bright = new FakeBright({});
const runtime = createOneTalkServiceWorkerSyncRuntime({
scope: { channelAccountId: "account-standalone", deviceId: "device-standalone" },
bright,
store: new EmptyStore(),
bootstrapStore: new EmptyBootstrapStore(),
profileStore: {
getProfile: async () => null,
listPendingProfiles: async () => [],
hasProfileRecord: async () => false,
putPendingProfile: async () => {
throw new Error("unexpected profile write");
},
markProfileUploaded: async () => false,
discardPendingProfile: async () => false,
},
onError: () => {},
});
const port = new FakePort(pageSender);
runtime.runtime.handleConnect(port);
port.dispatchMessage(createOneTalkPageHelloMessage("account-standalone"));
bright.emitStatus("authenticated");
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
port.posted.map((message) => message.command?.action),
["onetalk.contact.snapshot"],
);
runtime.dispose();
});
test("does not route an initial snapshot after Bright authentication is lost during the ledger check", async () => {
let authenticated = true;
let releaseLedgerCheck;
const ledgerCheck = new Promise((resolve) => {
releaseLedgerCheck = resolve;
});
const profile = {
getChannelAccountId: () => "account-auth-race",
hasProfileRecord: () => ledgerCheck,
isAuthenticated: () => authenticated,
handleStatus: (state) => {
authenticated = state.status === "authenticated";
},
handlePageReady: async () => {},
handlePageDisconnected: () => {},
};
const host = new OneTalkPageRuntimeHost({
getActiveEngine: () => ({ handlePageReady: async () => {} }),
getActiveProfileCoordinator: () => profile,
getActiveChannelAccountId: () => "account-auth-race",
onError: () => {},
});
const port = new FakePort(pageSender);
host.runtime.handleConnect(port);
port.dispatchMessage(createOneTalkPageHelloMessage("account-auth-race"));
await Promise.resolve();
profile.handleStatus({ status: "closed", permissions: [] });
releaseLedgerCheck(false);
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(
port.posted.filter((message) => message.type === "onetalk.page.command"),
[],
);
});
test("disposes the runtime's Bright subscriptions and socket", () => {
const bright = new FakeBright({});
const runtime = createOneTalkServiceWorkerSyncRuntime({
@@ -463,11 +539,17 @@ test("does not let a delayed old page callback touch the replacement account", a
let oldProfileReadyCalls = 0;
let nextProfileReadyCalls = 0;
const oldProfile = {
getChannelAccountId: () => "account-a",
hasProfileRecord: async () => false,
isAuthenticated: () => true,
handlePageReady: async () => {
oldProfileReadyCalls += 1;
},
};
const nextProfile = {
getChannelAccountId: () => "account-b",
hasProfileRecord: async () => false,
isAuthenticated: () => true,
handlePageReady: async () => {
nextProfileReadyCalls += 1;
},
@@ -502,6 +584,6 @@ test("does not let a delayed old page callback touch the replacement account", a
assert.equal(oldProfileReadyCalls, 0);
assert.equal(port.posted.length, initialPostCount);
assert.equal(nextProfileReadyCalls, 1);
assert.equal(replacementPort.posted.length, replacementPostCount);
assert.equal(replacementPort.posted.length, replacementPostCount + 1);
assert.equal(replacementPort.posted.at(-1).command.action, "onetalk.contact.snapshot");
});