feat(onetalk): sync active contact profiles

This commit is contained in:
YBF
2026-09-13 14:27:36 +08:00
parent ce61b4cc72
commit f93255e8d9
20 changed files with 706 additions and 146 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
@@ -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");
});