Files
trade-message-center/.trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md
T

10 KiB

OneTalk 联系人资料观察、Bright 持久化与 direct discovery 契约

1. Scope / Trigger

当 OneTalk 页面需要把已加载的单聊联系人资料和会话事实送入 Bright 时,遵循本契约。页面只读取页面拥有的会话快照/更新,Service Worker 通过现有 Bright WebSocket 发送;联系人资料的 canonical owner 是 Bright PostgreSQL,不再投递到 Mind profile endpoint。群聊、未确认客户身份的 CRM 头像和页面原始行不进入跨边界事实。

2. Signatures

type OneTalkContactProfile = {
    conversationId: string;
    aliId: string;
    accountId: string | null;
    loginId: string | null;
    name: string | null;
    companyName: string | null;
    countryCode: string | null;
    currentTimeZone: number | null;
    serviceType: string | null;
    avatarUrl: string | null;
    observedAtMs: number;
    profileFingerprint: string;
    observationStatus: "confirmed" | "partial";
};

createOneTalkContactProfileObservedFrame(
    context: { connectionType: "plugin"; requestId: string; scope: OneTalkPluginScope },
    profiles: OneTalkContactProfile[],
): OneTalkContactProfileObservedFrame;

OneTalkBrightClient.sendConversationDiscovered(conversationId: string): boolean;
OneTalkBrightClient.sendContactProfiles(input: {
    profiles: OneTalkContactProfile[];
    requestId?: string;
}): string | null;

contactProfileKey(channelAccountId: string, conversationId: string): string;

OneTalkContactProfileStore.putPendingProfile(
    channelAccountId: string,
    profile: OneTalkContactProfile,
): Promise<OneTalkContactProfileLedgerRecord>;
OneTalkContactProfileStore.markProfileUploaded(input: {
    channelAccountId: string;
    conversationId: string;
    fingerprint: string;
    observedAtMs: number;
    uploadedAt: number;
}): Promise<boolean>;
OneTalkContactProfileStore.discardPendingProfile(input: {
    channelAccountId: string;
    conversationId: string;
    fingerprint: string;
    observedAtMs: number;
    rejectedAt: number;
}): Promise<boolean>;

3. Contracts

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.
  • 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.
  • Avatar candidates are validated absolute HTTP(S) URLs. CRM avatar data is accepted only after buyerInfo and the OneTalk customer identifier match; otherwise the observation is discarded.
  • conversation.discovered is emitted with exactly conversationType: "direct". The client does not guess a conversation type from UI labels, and the server does not infer direct from a missing value.

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.
  • 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.

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.
  • 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.

Sensitive-field boundary

chatToken, aliIdEncrypt, accountIdEncrypt, loginIdEncrypt, kHTAccessToken, Cookie, raw rows/responses and Mind user/workspace fields never cross MAIN→bridge, page→Port, Bright business payloads, diagnostics or the profile ledger. deviceId remains only in the existing plugin connection scope/connection-integrity context; it is not a profile field or database key.

4. Validation & Error Matrix

Condition Result
Missing conversationId/aliId, invalid fingerprint/time/status or extra profile key reject as invalid_message; no send
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
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

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.
  • 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.

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".
  • 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

Wrong

await mindProfileDelivery({ channelAccountId, binding, profiles: rawRows });
await store.markProfileUploaded({ channelAccountId, aliId, fingerprint, uploadedAt });

Correct

await store.putPendingProfile(channelAccountId, profile);
const requestId = bright.sendContactProfiles({ profiles: [profile] });
// On the matching ACK, mark only the current pending [account, conversation, fingerprint, time].
await store.markProfileUploaded({
    channelAccountId,
    conversationId: profile.conversationId,
    fingerprint: profile.profileFingerprint,
    observedAtMs: profile.observedAtMs,
    uploadedAt: Date.now(),
});

Design Decision: Bright owns profile facts

The profile is a Bright-readable fact because the conversation read API must combine the current snapshot in memory without a second Mind data path or a SQL JOIN. The existing plugin WebSocket remains the transport and authorization boundary; a separate Mind delivery adapter would create a second owner, duplicate retry/ACK semantics and make the account/conversation invariant unverifiable.