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__andim-conversation-list:syncData; it never forwards a raw row or response. channelAccountIdcomes only from the logged-in page identity (currentUserAccountIdorIcbuIM.UserUtil.currentUser.accountId). URLactiveAccountIdidentifies the selected counterpart and is never an account fallback.- The durable profile key is
[channelAccountId, conversationId];aliIdremains 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 abusiness_cardwhile 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
buyerInfoand the OneTalk customer identifier match; otherwise the observation is discarded. conversation.discoveredis emitted with exactlyconversationType: "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.observedis a plugin-direction frame withprofiles.lengthin1..100and serialized UTF-8 size at most256 KiB; every profile has the exact 13 whitelisted fields above. contact.profile.ackis 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,
syncoperation andreadpermission. 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 withws.errorcodeprofile_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_VERSIONis8. The independentonetalk_contact_profilesstore 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
observedAtMsreplaces an older pending snapshot. A profile with anobservedAtMsat 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
observedAtMsstill 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 byaliId, 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 KiBbyte 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 --checkare 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.