mirror of
https://github.com/sinanyuntu/trade-message-center.git
synced 2026-09-17 13:22:11 +08:00
feat: sync OneTalk customer avatar profiles
This commit is contained in:
@@ -17,6 +17,7 @@ type OneTalkContactProfile = {
|
||||
countryCode: string | null;
|
||||
currentTimeZone: number | null;
|
||||
serviceType: string | null;
|
||||
avatarUrl: string | null;
|
||||
observedAtMs: number;
|
||||
profileFingerprint: string;
|
||||
observationStatus: "confirmed" | "partial";
|
||||
@@ -52,13 +53,14 @@ OneTalkContactProfileStore.markProfileUploaded(input: {
|
||||
- `channelAccountId` comes only from `currentUserAccountId` or `IcbuIM.UserUtil.currentUser.accountId`. URL `activeAccountId` is the selected counterpart and is never a fallback.
|
||||
- The profile scope key is `[channelAccountId, aliId]`; `conversationId` is source context and `loginId` is display/assistance data.
|
||||
- The page profile envelope contains `channelAccountId`. Service Worker accepts it only when it equals the last valid hello on the same Port and the active coordinator account.
|
||||
- SDK avatar candidates are read in one MAIN-world extractor with the order `contact.fullPortrait` → row `fullPortrait` → row `avatarUrl` → row `portraitUrl`; invalid candidates are skipped and the shared avatar URL validator owns the accepted shape. CRM avatar observations are passive and may explicitly produce `avatarUrl: null` only after matching `buyerInfo` is confirmed.
|
||||
|
||||
### Wire and Mind boundary
|
||||
|
||||
- `contact.profile.observed` is a non-empty plugin frame with `profiles.length` in `1..100`; its complete serialized frame is at most `256 KiB`.
|
||||
- `contact.profile.ack` is a plugin-direction server frame with `status: "delivered"` and a positive `profileCount`; it means only that Mind returned an HTTP response, not that Mind committed business data.
|
||||
- Both profile frames use the existing plugin binding, `sync` operation and `read` permission. No second socket, credential, Cookie, Mind user/workspace or page encryption parameter is introduced.
|
||||
- Mind request is `POST /internal/bright/onetalk/contact-profiles` with only `{ channelAccountId, binding, profiles }`. The adapter explicitly picks the 12 approved profile fields and does not read the response body.
|
||||
- Mind request is `POST /internal/bright/onetalk/contact-profiles` with only `{ channelAccountId, binding, profiles }`. The adapter explicitly picks the 13 approved profile fields, including `avatarUrl: string | null`, and does not read the response body.
|
||||
|
||||
### Durable lifecycle
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ contact.profile.observed -> contact.profile.ack { status: "delivered"; profileCo
|
||||
- The handler uses the registry canonical connection and current authorization result; client-supplied Mind user/workspace values are never authority. The delivery result is awaited before ACK.
|
||||
- After HTTP response headers arrive, the handler reauthorizes and rechecks binding, complete Mind scope, authorization version, `read`, policy epoch, canonical connection and commit guard. Revocation, version change, permission removal, pause or replacement suppresses a late ACK.
|
||||
- Any HTTP response status class is `delivered`; fetch rejection, timeout or no response is `no_response` and never ACKs. Response bodies are not read, cached, logged or returned.
|
||||
- The adapter builds each profile with an explicit `Pick` of the shared 12 fields. Runtime extra keys cannot cross the HTTP boundary.
|
||||
- The adapter builds each profile with an explicit `Pick` of the shared 13 fields, including `avatarUrl: string | null`. Runtime extra keys cannot cross the HTTP boundary; `avatarUrl` is passed only as the validated URL string or explicit `null`.
|
||||
- `onContactProfileUpload` is a separate development-only observability sink: `dev-entry` prints the already decoded whitelist profile after Bright receives it; the production entry does not register this sink. It is not part of safe diagnostics and must not be wired to production logging.
|
||||
- `contact.profile.observed` with an empty array is rejected by the shared decoder before authorization/delivery. There is no empty-batch ACK.
|
||||
- `AppDependencies.contactProfileDelivery` is the test/deployment port. Without a delivery dependency, the profile branch fails closed and does not fabricate an ACK.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{"file": ".trellis/spec/guides/index.md", "reason": "独立检查的跨包思考清单入口。"}
|
||||
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "检查 avatarUrl 在各边界的合同和数据流一致性。"}
|
||||
{"file": ".trellis/spec/project/architecture.md", "reason": "检查文件职责、共享 contract 和 scope。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/index.md", "reason": "检查扩展质量和构建约束。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md", "reason": "检查 profile ledger、ACK、账号隔离和安全白名单回归。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md", "reason": "检查页面桥 exact envelope、路由和 fail-closed。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md", "reason": "检查 durable-first 和重连语义。"}
|
||||
{"file": ".trellis/spec/server/backend/index.md", "reason": "检查服务端边界和验证基线。"}
|
||||
{"file": ".trellis/spec/server/backend/mind-contact-profile.md", "reason": "检查 Bright→Mind 显式 whitelist、response body 和授权 fence。"}
|
||||
{"file": ".trellis/spec/server/backend/error-handling.md", "reason": "检查错误/诊断中的敏感字段泄漏。"}
|
||||
{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "检查验证命令状态和服务端质量门槛。"}
|
||||
{"file": ".trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/onetalk-customer-profile-cross-layer.md", "reason": "父任务链路研究作为独立 review 的兼容性基线。"}
|
||||
{"file": ".trellis/tasks/09-01-onetalk-customer-avatar-profile/research/page-observation.md", "reason": "独立检查页面来源、CRM 被动观察和身份隔离。"}
|
||||
{"file": ".trellis/tasks/09-01-onetalk-customer-avatar-profile/research/contract-ledger.md", "reason": "独立检查 contract、durable ledger、ACK 和跨层兼容性。"}
|
||||
{"file": ".trellis/tasks/09-01-onetalk-customer-avatar-profile/research/server-mind-boundaries.md", "reason": "独立检查 server/Mind/mock 安全与 external boundary。"}
|
||||
@@ -0,0 +1,95 @@
|
||||
# OneTalk 客户头像 URL 采集与同步设计
|
||||
|
||||
## 1. 目标、不变量与边界
|
||||
|
||||
本任务只扩展父任务已经建立的联系人资料链路,增加客户/买家头像 URL。唯一允许跨边界的头像值是经过清洗的 `string | null`:有效 URL 作为字符串传递;页面已确认头像字段但没有有效 URL 时显式传 `null`,用于清除旧值。任何图片二进制、页面 `<img>`、原始 OneTalk row、原始 CRM JSONP 参数、Cookie、token 和加密 ID 都不得进入页面桥、Service Worker、Bright、Mind、日志或持久化。
|
||||
|
||||
核心不变量:
|
||||
|
||||
1. `channelAccountId` 始终来自真实登录用户;`activeAccountId` / `data-cid` 只作为客户会话上下文,不能作为卖家身份。
|
||||
2. SDK 与 CRM 观察在 MAIN world 内按同一客户 `externalCustomerId`(现有 profile 的 `aliId`)合并;最终只产生一个已批准的 `OneTalkContactProfile`。
|
||||
3. CRM 字段只有在 CRM 明确确认时才覆盖已有值;特别是“确认无头像”的 `null` 与“CRM 未提供头像”必须可区分。
|
||||
4. 头像 fingerprint 与其它 profile 字段共用父任务的 profile ledger;上传、ACK、迟到 ACK、断线恢复和账号隔离不另建状态机。
|
||||
5. 采集是被动的:扩展只读取页面已经自然加载的数据,不主动请求 CRM、不打开客户卡、不创建 enrichment job、不扫描 DOM 图片。
|
||||
|
||||
仓库当前没有独立 Workbench customers UI 或 `/collector/v1/sync-batches` 实现;实现包必须先以研究结果确认现有可接入边界。若真实 Workbench/Mind collector 不在本仓库,代码验收只覆盖已存在的共享 contract、Bright adapter、mock/store,并将真实联调记为 `external_unverified`,不凭空制造第二套业务链路。
|
||||
|
||||
## 2. 数据流
|
||||
|
||||
```text
|
||||
OneTalk MAIN world
|
||||
├─ 已加载 conversation SDK/map
|
||||
│ └─ safe SDK profile + avatar candidate
|
||||
└─ 页面自然产生的 CRM JSONP
|
||||
└─ buyerInfo avatar whitelist + selected data-cid context
|
||||
↓ source-local merge, identity and URL validation
|
||||
OneTalkContactProfile { avatarUrl: string | null }
|
||||
↓ page postMessage / isolated Port
|
||||
Service Worker profile ledger (same account + aliId key)
|
||||
↓ existing plugin WebSocket
|
||||
Bright contact.profile.observed
|
||||
↓ existing binding/read/sync authorization + explicit Mind pick
|
||||
Mind profile/collector endpoint or repository-backed mock
|
||||
↓ headers received
|
||||
contact.profile.ack → ledger latest fingerprint mark uploaded
|
||||
```
|
||||
|
||||
SDK 初始快照和 `syncData` 都调用同一个会话 row 白名单提取函数。CRM 只在页面已有 callback 被调用时读取 `buyerInfo`;MutationObserver 仅负责发现匹配的新增/已有 script,不触发网络请求。当前选中的唯一 `data-cid` 和登录账号必须在 callback 处理时重新校验,无法确定关联时丢弃并记录脱敏原因。
|
||||
|
||||
## 3. 合同与合并模型
|
||||
|
||||
### 3.1 共享 profile contract
|
||||
|
||||
在 `apps/onetalk-contract/src/model.ts` 的 `OneTalkContactProfile` 增加:
|
||||
|
||||
```ts
|
||||
avatarUrl: string | null;
|
||||
```
|
||||
|
||||
`decoder.ts` 保持 profile frame 的 exact-key、方向、batch 和 frame-size 约束,并接受 `null` 或经过边界规范化的非空 URL-like 字符串;任意其它字段仍然 fail closed。生产提取器不得把占位值(例如 `"a"`)送入 decoder。
|
||||
|
||||
### 3.2 页面内 source-local records
|
||||
|
||||
页面层使用不跨边界的内部观察模型表达字段确认状态,例如 SDK observation 与 CRM observation 分开保存:
|
||||
|
||||
- SDK 对 `contact.fullPortrait`、row 顶层 `fullPortrait`、`avatarUrl`、`portraitUrl` 按固定优先级取值;没有有效候选时只保留 SDK 的“未确认/无候选”状态。
|
||||
- CRM 只从 `buyerInfo.avatarUrl`、`fullPortrait`、`profilePicUrl`、`profilePictureUrl`、`imgUrl`、`portraitUrl`、`headImg`、`headerSmallPath` 取值;匹配的 callback 已明确返回 `buyerInfo` 但候选无效/缺失时,记录 `avatarConfirmed: true, avatarUrl: null`。
|
||||
- 最终 profile 按 `aliId` 合并,CRM 已确认字段覆盖 SDK 同源字段;CRM 未确认的字段不覆盖已有 SDK 值。最终输出没有 source/raw-row 字段,只有共享 contract 白名单。
|
||||
|
||||
URL validator 必须在一个明确的位置复用,拒绝空白、单字符占位、`javascript:`/`data:`/`blob:` 等非头像 URL 协议;是否接受 OneTalk 实际使用的协议相对 URL 由研究包用真实页面证据确认,并在测试中固定。
|
||||
|
||||
## 4. 语义边界与写入包
|
||||
|
||||
任务为大任务,包状态在 `implement.md` 维护。每次只有一个 implement writer 活跃,所有包串行执行;测试可由实现者在对应包中添加,独立 check worker 只能修改测试文件。
|
||||
|
||||
| 包 | owner | 精确写入范围 | 输入 | 产出与验收 | 依赖 | 解锁 |
|
||||
| --- | --- | --- | --- | --- | --- | --- |
|
||||
| P0 Research | `trellis-research` | `{TASK_DIR}/research/avatar-cross-layer.md` | PRD、父任务产物、当前源码 | 真实字段/入口/缺口证据,确认 CRM callback、SDK avatar 字段、Mind/Workbench 边界 | 无 | P1-P5 |
|
||||
| P1 Contract | `trellis-implement` | `apps/onetalk-contract/src/**`、对应 `apps/onetalk-contract/test/**` | P0、父 contract | `avatarUrl` 类型/decoder/构造器和敏感字段回归;URL/null exact semantics | P0 | P2-P5 |
|
||||
| P2 Page observation | `trellis-implement` | `apps/chrome-extension/src/onetalk/main-page/**`、`apps/chrome-extension/src/onetalk/page-bridge/**`、对应 observer/bridge tests | P0/P1 | SDK 候选、被动 CRM JSONP、账号与 `data-cid` fail-closed、source merge、无 raw leak | P1 | P3 |
|
||||
| P3 Ledger/runtime | `trellis-implement` | `apps/chrome-extension/src/onetalk/service-worker/**`、对应 profile/runtime/storage tests | P1/P2 | fingerprint、pending、ACK、断线/迟到 ACK/账号隔离复用现有 ledger | P1/P2 | P4 |
|
||||
| P4 Bright/Mind delivery | `trellis-implement` | `apps/server/src/mind-contact-profile.ts`、`apps/server/src/websocket/**`、对应 server tests | P0/P1/P3 | explicit `avatarUrl` whitelist、现有授权与 ACK fence 不变、无原始响应/body 泄漏 | P1 | P5 |
|
||||
| P5 Mind mock/store and docs | `trellis-implement` | `apps/mind-http-mock/**`、相关 fixture/tests、任务文档允许的同步说明 | P0/P1/P4 | mock/store 保留 URL;若 Workbench 不在仓库,记录 external boundary,不造 UI | P4 | Check |
|
||||
|
||||
将 P4 与 P5 拆开是因为服务端生产投递契约和 mock 的存储/观测是两个独立可验证的语义边界;将 P2 与 P3 拆开是为了保持页面采集与 durable delivery 的不变量分别归属。
|
||||
|
||||
## 5. 失败、隐私和兼容性
|
||||
|
||||
- 页面 SDK 能力缺失、登录身份缺失、账号切换、无唯一 `data-cid`、CRM callback 无法安全关联:fail closed,保留脱敏诊断,不发 profile。
|
||||
- URL 已确认但无效:合并结果为 `avatarUrl: null`,只有 fingerprint 变化时才通过现有 ledger 上传。
|
||||
- Bright 离线、Mind 无 response、ACK mismatch、IndexedDB 写入失败:沿用现有 pending/retry 语义,不影响消息同步。
|
||||
- Bright 只接受共享 decoder 已批准的 profile,HTTP body 由显式 `Pick` 构造;不新增 Bright DB 客户表、不读取 Mind response body。
|
||||
- `tm-binding-avatar` 若存在于仓库外的 Workbench,继续保持渠道 logo 语义;头像只绑定 `customers[].avatarUrl`,不能复用 binding avatar 字段。
|
||||
|
||||
## 6. 验收矩阵
|
||||
|
||||
| 验收项 | 证据 |
|
||||
| --- | --- |
|
||||
| AC1 SDK snapshot + syncData | P2 focused tests:字段优先级、同一提取器、无 DOM img/下载 |
|
||||
| AC2 被动 CRM JSONP | P2 tests:script/callback hook、无主动请求、buyerInfo 白名单、`data-cid`/账号隔离 |
|
||||
| AC3 合并与 null | P1/P2 tests:CRM 覆盖、未确认不覆盖、无效/缺失确认清空、卖家头像不进入 |
|
||||
| AC4 跨层合同 | P1/P3/P4 tests:exact key、page envelope、ledger、Bright→Mind body、`customers[].avatarUrl` 可接入处 |
|
||||
| AC5 安全 | decoder/page/adapter/mock tests:不含 raw row、callback 参数、Cookie、token、加密 ID、图片二进制 |
|
||||
| AC6 durable | P3 tests:latest-wins、重连、ACK fence、账号/epoch 隔离 |
|
||||
| AC7 mock/Workbench | P5 test;真实 Workbench/Mind 生产联调若无环境标为 external_unverified |
|
||||
| AC8 回归 | `pnpm format:check`、`pnpm typecheck`、`pnpm test`、`pnpm build`、`git diff --check` 及必要 focused smoke |
|
||||
@@ -0,0 +1,41 @@
|
||||
# Finding ledger
|
||||
|
||||
任务:OneTalk 客户头像 URL 采集与同步
|
||||
|
||||
## 状态约定
|
||||
|
||||
- `open`:已确认但未修复。
|
||||
- `in_progress`:已回派实现者。
|
||||
- `fixed`:实现者声称修复,等待独立复核。
|
||||
- `closed`:独立复核已用精确复现关闭。
|
||||
- `external_unverified`:本地代码无法取得的真实 OneTalk/CRM/Mind/Workbench 证据,不作为本地实现 blocker。
|
||||
|
||||
## Findings
|
||||
|
||||
研究报告确认的外部边界(真实 OneTalk/CRM callback、头像 URL 协议、Mind collector、Workbench 和 PostgreSQL)按 `external_unverified` 处理,不伪装为本地通过。以下 finding ID 在后续修复轮次保持不变。
|
||||
|
||||
## Package ledger
|
||||
|
||||
| 包 | 状态 | owner | 最新证据 |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 Research | accepted | trellis-research R1/R2/R3 | `research/page-observation.md`、`contract-ledger.md`、`server-mind-boundaries.md` 已完成并核对 |
|
||||
| P1 Contract | accepted | trellis-implement Hilbert | 3 个 contract 文件已核对;20/20 源码与构建测试、typecheck、format、diff check 通过 |
|
||||
| P2 Page observation | accepted | trellis-implement Newton | 页面 focused 22/22、typecheck/build、format、diff check 通过;真实 CRM/Chromium 为 external_unverified |
|
||||
| P3 Ledger/runtime | accepted | trellis-implement Ramanujan | focused 46/46、扩展 174/174、typecheck/build、format、diff check 通过 |
|
||||
| P4 Bright/Mind delivery | accepted | trellis-implement Hypatia | adapter/WS focused 17/17、server 源码/构建测试各 87/87、typecheck/build/format/diff check 通过 |
|
||||
| P5 Mind mock/store and docs | accepted | trellis-implement Avicenna | mock 源码/构建测试各 10/10、typecheck/build/format/diff check 通过;collector/Workbench external_unverified |
|
||||
| Independent check | accepted | trellis-check Nietzsche | 第 1 轮复核终态:F-001/F-002/F-003 已关闭,无新的 local finding;根 pnpm 构建型命令因 pre-hook 写 dist 标记 blocked |
|
||||
|
||||
## Findings
|
||||
|
||||
| ID | invariant / locus | severity / locus | status | owner | reproducer / latest evidence |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| F-001 | CRM script 只应按目标 endpoint 识别,不应匹配任意 URL query 文本;`apps/chrome-extension/src/onetalk/main-page/contact-observer/crm-observer.ts:56` | blocking_local | closed | P2 implement owner Newton | checker 第 1 轮复核:query/fragment 误匹配、合法 endpoint、子路径/后缀和 pagehide cleanup 矩阵通过;原 reproducer 1/1 passed |
|
||||
| F-002 | 已实现的 `avatarUrl` 应同步到 profile/HTTP whitelist 规范 | non_blocking | closed | main/spec maintainer | checker 第 1 轮复核确认 Chrome/server specs 已为 13 fields 并记录 URL/null 语义 |
|
||||
| F-003 | 复杂任务研究汇总与 manifest seed 应完成清理 | out_of_scope | closed | main/task maintainer | checker 第 1 轮复核确认 `research/avatar-cross-layer.md` 存在且两份 manifest 无 `_example` |
|
||||
| F-004 | 真实 OneTalk/CRM/Chromium/Mind/Workbench/collector/PostgreSQL 证据 | external_unverified | open | external environment | checker 未等待外部系统;本地 mock 明确保持 `/collector/v1/sync-batches` 404 |
|
||||
|
||||
## Rework rounds
|
||||
|
||||
- 当前收敛目标:1 / 4
|
||||
- 同一 invariant 连续出现:0 轮(F-001 首轮)
|
||||
@@ -0,0 +1,16 @@
|
||||
{"file": ".trellis/spec/guides/index.md", "reason": "跨包任务的通用思考清单和跨层边界入口。"}
|
||||
{"file": ".trellis/spec/guides/cross-layer-thinking-guide.md", "reason": "头像字段穿过 contract、页面桥、ledger、Bright 和 Mind 的数据流检查。"}
|
||||
{"file": ".trellis/spec/guides/code-reuse-thinking-guide.md", "reason": "复用既有 profile extraction、fingerprint、ledger 和显式 whitelist,避免第二套实现。"}
|
||||
{"file": ".trellis/spec/project/architecture.md", "reason": "项目级职责边界、共享 contract 和 main-last 约束。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/index.md", "reason": "扩展包目录、构建和验证基线。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md", "reason": "既有联系人资料白名单、ledger、Bright frame、ACK 和账号隔离契约。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md", "reason": "MAIN/ISOLATED/Service Worker 页面桥、精确路由和 fail-closed 边界。"}
|
||||
{"file": ".trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md", "reason": "IndexedDB durable-first、ACK、重连和状态隔离约束。"}
|
||||
{"file": ".trellis/spec/server/backend/index.md", "reason": "服务端包边界和验证基线。"}
|
||||
{"file": ".trellis/spec/server/backend/mind-contact-profile.md", "reason": "现有 Bright→Mind profile HTTP whitelist、响应和授权 fence。"}
|
||||
{"file": ".trellis/spec/server/backend/error-handling.md", "reason": "跨 WebSocket/HTTP 的稳定错误和敏感字段边界。"}
|
||||
{"file": ".trellis/spec/server/backend/quality-guidelines.md", "reason": "服务端格式、类型、构建和测试命令。"}
|
||||
{"file": ".trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/onetalk-customer-profile-cross-layer.md", "reason": "父任务已验证的 profile 链路、账号语义和持久化边界。"}
|
||||
{"file": ".trellis/tasks/09-01-onetalk-customer-avatar-profile/research/page-observation.md", "reason": "页面 SDK、selected data-cid、CRM JSONP 缺口和安全边界研究。"}
|
||||
{"file": ".trellis/tasks/09-01-onetalk-customer-avatar-profile/research/contract-ledger.md", "reason": "共享 contract、page bridge、ledger、fingerprint、ACK 和 epoch 研究。"}
|
||||
{"file": ".trellis/tasks/09-01-onetalk-customer-avatar-profile/research/server-mind-boundaries.md", "reason": "Bright/Mind adapter、授权 fence、mock 和 Workbench 外部边界研究。"}
|
||||
@@ -0,0 +1,103 @@
|
||||
# OneTalk 客户头像 URL 采集与同步执行计划
|
||||
|
||||
## 1. 执行规则
|
||||
|
||||
- 当前任务激活后按 P0 → P1 → P2 → P3 → P4 → P5 串行执行;每个包完成后必须检查实际 diff、报告路径和验证结果,不能凭进度消息解锁依赖包。
|
||||
- 只有一个 `trellis-implement` writer;保持同一实现者的 ownership continuity。实现者不得 stage、commit、spawn 其它 worker 或回滚无关改动。
|
||||
- `trellis-check` 在全部实现包完成后独立检查实际 diff;发现生产代码问题必须回派给同一 implement owner,check worker 只可修改测试文件。
|
||||
- 当前工作树已有父任务归档元数据改动和本任务目录未跟踪文件;所有 worker 必须保留无关改动。
|
||||
|
||||
## 2. Package state
|
||||
|
||||
| 包 | 状态 | owner | acceptance gate |
|
||||
| --- | --- | --- | --- |
|
||||
| P0 Research | ready | trellis-research | `{TASK_DIR}/research/avatar-cross-layer.md` 存在且有源码/文档证据 |
|
||||
| P1 Contract | blocked | trellis-implement | P0 accepted;contract tests 覆盖 avatar string/null/exact keys |
|
||||
| P2 Page observation | blocked | trellis-implement | P1 accepted;SDK/CRM/merge/security tests |
|
||||
| P3 Ledger/runtime | blocked | trellis-implement | P1/P2 accepted;durable/ACK/reconnect tests |
|
||||
| P4 Bright/Mind delivery | blocked | trellis-implement | P1/P3 accepted;adapter/ws auth/body tests |
|
||||
| P5 Mind mock/store and docs | blocked | trellis-implement | P4 accepted;mock retains URL or external boundary explicitly recorded |
|
||||
| Independent check | blocked | trellis-check | P5 accepted;report has stable finding IDs and command states |
|
||||
|
||||
## 3. P0 research brief
|
||||
|
||||
研究者只读源码与现有任务/规范,写入 `{TASK_DIR}/research/avatar-cross-layer.md`,不得修改应用代码。必须回答:
|
||||
|
||||
1. 当前 SDK conversation row 的头像真实字段/优先级、`syncData` 入口和单聊/群聊判断;
|
||||
2. `OneTalkPageWindow`、page bridge、Service Worker profile ledger 的最小接入点;
|
||||
3. 是否已有 CRM script/JSONP callback 观察能力、当前 selected `data-cid` 的可靠来源,以及 fail-closed 所需上下文;
|
||||
4. `OneTalkContactProfile`、decoder、Bright adapter、Mind mock/store 和 Workbench/collector 的现状;
|
||||
5. 头像 URL 实际协议形态和安全 validator 的边界;
|
||||
6. 既有测试、构建、真实浏览器/生产 Mind 联调哪些可以执行,哪些属于 `external_unverified`。
|
||||
|
||||
研究报告需列出建议改动文件、不能改动的敏感字段、与父任务兼容性风险和可执行验收用例。报告完成且内容可核验后,P0 才转 accepted。
|
||||
|
||||
## 4. Serial implementation checklist
|
||||
|
||||
### P1 Contract — ready after P0
|
||||
|
||||
- [ ] 在共享 model 中加入 `avatarUrl: string | null`,更新所有 profile fixture/构造器。
|
||||
- [ ] 更新 decoder exact profile keys 与 `avatarUrl` 类型边界,保留 frame direction/batch/size/敏感字段 fail-closed。
|
||||
- [ ] 若研究确认需要共享 URL validator,只在一个明确的 contract/source boundary 定义并测试,拒绝 placeholder 与非 URL 协议;避免重复 validator。
|
||||
- [ ] 添加 contract tests:有效 URL、显式 null、无效值、额外字段、敏感字段、旧消息/发送/授权 frame 回归。
|
||||
- [ ] 运行 `pnpm --filter @trade-message-center/onetalk-contract test`、typecheck/format(按 package scripts 实际存在情况记录)。
|
||||
|
||||
### P2 Page observation — ready after P1
|
||||
|
||||
- [ ] 让 SDK snapshot 与 `syncData` 共用 profile extraction/merge 入口,读取 `contact.fullPortrait` → row `fullPortrait` → `avatarUrl` → `portraitUrl`。
|
||||
- [ ] 增加被动 CRM observer:只匹配 `queryCustomerInfo` / `queryCustomerTag` script,hook 已注册 callback,读取 `buyerInfo` 头像白名单,不发起 CRM 请求。
|
||||
- [ ] 增加 selected `data-cid` 与真实登录 `channelAccountId` 校验;无法关联、账号切换或 callback 身份不明确时 fail closed。
|
||||
- [ ] 保证 CRM 已确认无有效头像的结果为 `avatarUrl: null`,未确认字段不覆盖 SDK profile;最终 payload 仍是严格 contract 白名单。
|
||||
- [ ] 保证诊断不含 raw callback args/row/token,脚本 observer 在 `pagehide` 清理 callback/script/event 资源。
|
||||
- [ ] 添加 focused tests:候选优先级、URL 校验、CRM JSONP、merge/null、账号/cid 隔离、无主动请求、无 DOM img。
|
||||
- [ ] 运行扩展 focused tests、`pnpm --filter @trade-message-center/chrome-extension typecheck` 和 format check(以实际 scripts 为准)。
|
||||
|
||||
### P3 Ledger/runtime — ready after P1/P2
|
||||
|
||||
- [ ] 将 `avatarUrl` 纳入现有 profile field names/fingerprint/pending/ACK 映射;不要新增第二个队列或 Bright store。
|
||||
- [ ] 核对 Service Worker/page runtime 的 profile envelope 和账号/epoch 路由,确保迟到旧 ACK 不会覆盖最新头像。
|
||||
- [ ] 添加/更新 IndexedDB fixture:新 URL、URL 变化、显式 null、pending 最新覆盖、ACK 后清理、断线恢复、账号隔离。
|
||||
- [ ] 添加 runtime/bridge regression,证明 profile failure 不影响 message observed/sync/send 三态。
|
||||
- [ ] 运行扩展全部 profile/runtime focused tests,再运行包级 typecheck/build。
|
||||
|
||||
### P4 Bright/Mind delivery — ready after P1/P3
|
||||
|
||||
- [ ] 更新 `mind-contact-profile.ts` 的显式 HTTP `Pick` 和 upload event;只允许 `avatarUrl` 字符串/null 跨 HTTP。
|
||||
- [ ] 更新 WebSocket handler 相关 profile frame fixture/diagnostics,保持 canonical binding、read/sync authorization、post-response fence、无 response 不 ACK。
|
||||
- [ ] 增加 tests:body 保留 URL/null、不读 response body、不含 raw/token/cookie、任意 HTTP status ACK、network/timeout pending、late ACK fence。
|
||||
- [ ] 不把 profile 写入 Bright repository/消息/anomaly/checkpoint,不添加原始 payload 日志。
|
||||
- [ ] 运行 server profile/websocket focused tests、typecheck、build 和 format check。
|
||||
|
||||
### P5 Mind mock/store and docs — ready after P4
|
||||
|
||||
- [ ] 根据 P0 证据扩展现有 Mind mock/store 的 profile 接收边界;保留 URL/null,不保存原始敏感字段。
|
||||
- [ ] 若本仓库无 Workbench/collector 实现,不新增虚假的 UI/业务同步;在任务研究/完成报告中将真实 `customers[].avatarUrl` smoke 标为 `external_unverified`,并提供现有 mock 可执行证据。
|
||||
- [ ] 添加 mock/store tests:URL 保留、null 清除、消息路径不被 profile 失败阻断。
|
||||
- [ ] 仅在已有文档与实现形成稳定模式后更新对应 `.trellis/spec` / `docs`,不把外部推测写成规范。
|
||||
|
||||
## 5. Validation order and gates
|
||||
|
||||
每个包至少报告以下状态:`passed`、`failed`、`skipped` 或 `blocked`,并保留命令和关键输出摘要。
|
||||
|
||||
1. Changed package focused tests。
|
||||
2. `pnpm format:check`。
|
||||
3. `pnpm typecheck`。
|
||||
4. `pnpm build`。
|
||||
5. `pnpm test`(若缺少 `TEST_DATABASE_URL`、真实 Chromium、Mind/Workbench 环境,明确记录边界)。
|
||||
6. `git diff --check`。
|
||||
7. 需要浏览器证据时,重载 unpacked extension,确认 MAIN/ISOLATED/Service Worker build hash 一致,执行真实 MCP/browser call;启动服务本身不算 smoke evidence。
|
||||
|
||||
## 6. Review and repair gate
|
||||
|
||||
- [ ] P0 research report accepted。
|
||||
- [ ] P1–P5 implement terminal reports verified,changed-file ownership 清晰。
|
||||
- [ ] 独立 `trellis-check` report terminal,包含检查文件、稳定 finding ID、每条 validation 状态。
|
||||
- [ ] blocking_local finding 由原 implement owner 修复;最多 4 个完整 rework rounds,第二轮同一 invariant 仍在时先做 root-cause/state-machine research。
|
||||
- [ ] 未验证的真实 CRM/OneTalk/Mind/Workbench 环境不得标成通过,归类为 `external_unverified`。
|
||||
- [ ] 通过最终 diff scope、spec compliance、cross-layer data flow 和安全字段扫描后,才进入 spec update、commit 和 finish-work。
|
||||
|
||||
## 7. Completion evidence
|
||||
|
||||
- P0 research、P1 contract、P2 page observation、P3 ledger/runtime、P4 Bright/Mind delivery 和 P5 local mock/store 均已由对应 worker 返回终态并核对实际 scope。
|
||||
- 首轮独立 review 的 F-001 matcher blocker 已由 P2 owner 修复;同一 checker 第 1 轮复核关闭 F-001/F-002/F-003,无新的 local finding。
|
||||
- 本地质量证据和外部边界记录见 `research/check-final.md`;真实 OneTalk/CRM/Mind/Workbench/Chromium 仍为 `external_unverified`。
|
||||
@@ -0,0 +1,68 @@
|
||||
# OneTalk 客户头像 URL 采集与同步
|
||||
|
||||
## Goal
|
||||
|
||||
在已完成的 OneTalk 基础客户资料同步之上,被动采集客户/买家头像 URL,合并会话 SDK 与 CRM 客户卡来源,并沿现有 profile ledger、Bright WebSocket 和 TradeMind customers[].avatarUrl 链路上传;不下载图片、不扫描 DOM img、不采集卖家头像。
|
||||
|
||||
## Background
|
||||
|
||||
- 父任务 `08-31-onetalk-customer-profile-fetch` 已完成基础单聊资料的 MAIN → page bridge → Service Worker → Bright → Mind 链路,但当前 profile contract 尚未包含 `avatarUrl`。
|
||||
- 旧 TradeBridge 的运行时探查确认:会话头像可能位于 `contact.fullPortrait`、会话顶层 `fullPortrait`,以及兼容字段 `avatarUrl`、`portraitUrl`;头像值只作为 URL 字符串保存。
|
||||
- OneTalk 客户卡自然加载时会发起 CRM JSONP。扩展只被动观察 `queryCustomerInfo` / `queryCustomerTag` 脚本和页面已有 callback,从 `buyerInfo` 白名单提取头像,不主动发 CRM 请求。
|
||||
- 现有 TradeBridge 约束仍然适用:`activeAccountId` 是当前选中的客户/对端,不是登录卖家;`channelAccountId` 必须来自真实登录用户。不得通过 URL、DOM 名称或列表下标推断身份。
|
||||
- 该 task 只扩展已加载资料的头像字段,不创建 profile enrichment job,不主动打开客户卡;只有页面自身自然产生的 SDK/CRM 数据才可被观察。
|
||||
|
||||
## Requirements
|
||||
|
||||
### R1. 会话 SDK 头像来源
|
||||
|
||||
- 从 `getConversationListByPagination()` / `__conversationListData__` 的安全会话对象读取头像候选值,优先使用 `contact.fullPortrait`,再按既定兼容顺序检查会话顶层 `fullPortrait`、`avatarUrl`、`portraitUrl`。
|
||||
- 初始快照和 `syncData` 增量都使用同一白名单提取函数;不得泛扫 `<img>`、下载图片、读取页面外部资源或序列化原始会话 row。
|
||||
- 头像字段变化必须更新 profile fingerprint;未变化的联系人不得重复上传。
|
||||
|
||||
### R2. CRM 客户卡被动观察
|
||||
|
||||
- 监听新增或已有 CRM `<script>`,仅匹配 `queryCustomerInfo` / `queryCustomerTag`,hook 页面已经注册的 JSONP callback;不得由扩展主动发 CRM 请求。
|
||||
- 只有用户已经触发的客户卡自然加载才可被动观察;选择或停留会话、扩展安装、后台定时器不得创建资料 enrichment job 或触发客户卡加载。
|
||||
- 从 `buyerInfo` 读取头像白名单:`avatarUrl`、`fullPortrait`、`profilePicUrl`、`profilePictureUrl`、`imgUrl`、`portraitUrl`、`headImg`、`headerSmallPath`。
|
||||
- 只传递清洗后的头像 URL、客户标识和当前选中的 `data-cid` 关联上下文;不得读取、记录或转发 `secReqToken`、`ctoken`、`_tb_token_`、Cookie、加密 ID、完整 JSONP 响应或 callback 原始参数。
|
||||
- CRM 自然加载数据按当前页面真实登录账号归属;身份缺失、账号不匹配或 `data-cid` 无法关联时 fail closed。
|
||||
|
||||
### R3. URL、空值与合并语义
|
||||
|
||||
- 只接受 URL-like 字符串;例如初始占位值 `"a"` 必须丢弃。头像已被确认但没有有效 URL 时,显式写入 `avatarUrl: null` 以清除旧头像。
|
||||
- 会话 SDK 与 CRM profile 按同一 `externalCustomerId` 合并;CRM profile delivery 覆盖同一客户的会话头像和其它同源字段,但不得覆盖未由 CRM 确认的字段。
|
||||
- `avatarUrl` 表示客户/买家头像,不得填入登录卖家头像;Workbench 的 `tm-binding-avatar` 渠道 logo 语义保持不变。
|
||||
|
||||
### R4. 跨层合同与持久化
|
||||
|
||||
- 将 `avatarUrl: string | null` 纳入共享 profile/customer contract、decoder、页面桥 envelope、Service Worker profile ledger、sync mapper 和 `/collector/v1/sync-batches` 的 `customers[].avatarUrl`。
|
||||
- 复用现有 profile ledger 的账号/联系人隔离、fingerprint、pending、ACK、断线恢复和迟到 ACK 防护;不新增 Bright 客户表或第二套上传队列。
|
||||
- Bright → Mind 只传递已批准的头像 URL 字符串及既有客户字段;不传图片二进制、Cookie、OneTalk token 或原始 CRM row。Mind mock/store 应保留该 URL。
|
||||
|
||||
### R5. 展示与回归
|
||||
|
||||
- Workbench 使用 `customers[].avatarUrl` 作为 `<img src>` 原始 URL;缺失或显式 `null` 时清除旧头像并显示既有占位表现。
|
||||
- 消息同步、会话历史、发件三态、binding 授权和既有 profile 字段语义不得改变。
|
||||
- 测试必须覆盖 SDK 候选字段、CRM JSONP 被动捕获、URL 校验/null 清空、会话与 CRM 合并优先级、账号/`data-cid` 隔离、敏感字段不泄漏、ledger 重试与 Workbench/mock 保留。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] AC1. 已加载会话快照和 `syncData` 能提取客户头像 URL;支持 `contact.fullPortrait`、顶层兼容字段及 URL-like 校验,不读取 DOM `<img>` 或下载图片。
|
||||
- [ ] AC2. CRM 自然加载的 `queryCustomerInfo` / `queryCustomerTag` JSONP 能被动提取 `buyerInfo` 头像白名单,并按 `data-cid` 关联会话;扩展不主动请求 CRM。
|
||||
- [ ] AC3. 会话头像和 CRM 头像按 `externalCustomerId` 确定性合并,CRM 已确认值覆盖会话值;无效/缺失的已确认头像能以 `null` 清空旧值;卖家头像和 `tm-binding-avatar` 不受影响。
|
||||
- [ ] AC4. `avatarUrl` 在共享 decoder、page bridge、profile ledger、Bright→Mind、`/collector/v1/sync-batches` 和 `customers[].avatarUrl` 间字段名与空值语义一致,既有消息/profile 合同回归通过。
|
||||
- [ ] AC5. 头像只以 URL 字符串跨层和持久化;测试证明图片二进制、原始 CRM row、Cookie、`secReqToken`、`ctoken`、`_tb_token_`、加密 ID 和 callback 原始参数不会泄漏。
|
||||
- [ ] AC6. 账号/绑定/页面 epoch、`data-cid` 关联、断线恢复、最新 fingerprint 和迟到 ACK 保护与父任务 profile ledger 语义一致;无效身份 fail closed。
|
||||
- [ ] AC7. Mind mock/store 能保留头像 URL,Workbench 使用该 URL 渲染;`null` 能清除旧头像,且不会阻断消息同步。
|
||||
- [ ] AC8. 相关 contract、chrome-extension、server、web 测试和类型检查通过;真实 OneTalk/CRM/Mind 生产联调若未具备则单独标记为 external_unverified。
|
||||
|
||||
## Notes
|
||||
|
||||
- 头像采集是跨 package、跨边界的结构性子任务;实现前应补充 `design.md` / `implement.md`,并在 `task.py start` 前完成 review gate。
|
||||
- 头像来源和 CRM callback 字段可能随 OneTalk bundle 变化,生产代码必须保留特征检测、白名单和可观测失败原因。
|
||||
|
||||
## Verification status (2026-09-01)
|
||||
|
||||
- 本地 contract、Chrome extension、server 和 Mind mock/store 的 URL/null、账号隔离、敏感字段、ledger/ACK 和消息链路回归已通过;完整证据见 `research/check-final.md`。
|
||||
- 真实 OneTalk/CRM JSONP、Chromium、生产 Mind/collector、TradeMind Workbench `customers[].avatarUrl` 渲染与 PostgreSQL integration 当前不可用,保持 `external_unverified`;本仓库未创建虚假的 collector/UI 实现。
|
||||
@@ -0,0 +1,23 @@
|
||||
# OneTalk avatar cross-layer research synthesis
|
||||
|
||||
本文件汇总 P0 页面、contract/ledger、server/Mind 三份研究报告,供实现与复核阶段快速定位;详细证据仍以同目录三份报告和父任务归档研究为准。
|
||||
|
||||
## 已确认的本地边界
|
||||
|
||||
- 共享 `OneTalkContactProfile` 是唯一 profile wire owner;当前实现前没有 `avatarUrl`。profile exact keys、plugin frame direction、非空 batch、256 KiB frame limit、账号 scope 和敏感字段拒绝均已有基线。
|
||||
- MAIN profile observer 的 snapshot 与 `im-conversation-list:syncData` 都经过 `profilesFromConversationMap()` / `profileFromConversationRow()`;真实登录账号来自 `currentUserAccountId` 或 `IcbuIM.UserUtil.currentUser.accountId`,不得使用 URL `activeAccountId`。
|
||||
- selected 会话唯一来源是 `.contact-item-container.selected[data-cid]`;零个或多个结果必须 fail closed。现有页面桥只转发已清洗 profile,Service Worker profile ledger 按 `[channelAccountId, aliId]` durable-first 保存 pending,ACK 只确认当前 fingerprint。
|
||||
- Bright profile handler 已拥有 canonical plugin/binding/read/sync authorization、Mind response-header-only、post-response reauthorization、policy/commit/canonical fence,并且不写 Bright profile/message/anomaly/checkpoint 表。
|
||||
- 本地 Mind mock 原先只有 binding/session authorization;本任务可增加与现有 adapter 同路径的本地 profile receipt/store,但不能把它称为真实 Workbench/collector。
|
||||
|
||||
## 头像实现约束
|
||||
|
||||
- SDK 候选顺序固定为 `contact.fullPortrait` → row 顶层 `fullPortrait` → `avatarUrl` → `portraitUrl`;无效候选继续检查下一项。候选必须经过单一共享 URL validator,最终只能是有效绝对 HTTP(S) URL 或 `null`。
|
||||
- CRM 只被动观察页面自然产生的 `queryCustomerInfo` / `queryCustomerTag` JSONP script/callback,从 `buyerInfo` 的头像白名单构造 source-local observation;不得发起 CRM 请求、打开客户卡、扫描 `<img>`、传原始 callback 参数/response、Cookie、token 或加密 ID。
|
||||
- CRM observation 在 callback 时重新读取真实登录账号和唯一 selected `data-cid`,再按 `aliId`/external customer identity 与当前 SDK profile 合并。CRM 明确确认但没有有效候选时输出 `avatarUrl: null`;CRM 未确认不得覆盖 SDK avatar。
|
||||
- avatar 进入既有 fingerprint、pending、chunk、Bright frame、Mind explicit pick、ACK 和 mock store;不得添加第二个队列、profile store 或消息投影。`/collector/v1/sync-batches`、Workbench `customers[].avatarUrl`、`tm-binding-avatar` 和真实生产 endpoint 不在本仓库。
|
||||
|
||||
## 验收与外部边界
|
||||
|
||||
- 本地可验证:contract/page bridge/ledger/Bright/Mind adapter/mock 的 string/null、exact-key、账号隔离、latest-wins、迟到 ACK、response body 不读取、敏感字段不泄漏和消息链路回归。
|
||||
- 必须单独标记 `external_unverified`:真实 OneTalk bundle 的头像字段/协议、CRM JSONP callback 形态和自然加载时序、Chromium suspend/resume、真实 Mind/TLS/collector、Workbench customers 渲染与 null 占位、PostgreSQL integration(缺 `TEST_DATABASE_URL` 时)。
|
||||
@@ -0,0 +1,26 @@
|
||||
# OneTalk avatar final independent review
|
||||
|
||||
- Checker: trellis-check Nietzsche
|
||||
- Date: 2026-09-01
|
||||
- Scope: contract, Chrome MAIN/page bridge/Service Worker profile lifecycle, Bright/Mind delivery, local Mind mock, docs/spec/task artifacts and full diff.
|
||||
|
||||
## Result
|
||||
|
||||
- F-001 `blocking_local`: closed. CRM script matching now checks only the URL pathname's terminal endpoint segment; query/fragment false positives, valid endpoint paths, suffix/subpath cases and pagehide cleanup passed.
|
||||
- F-002 `non_blocking`: closed. Chrome and server profile specs now describe `avatarUrl: string | null` and the 13-field whitelist.
|
||||
- F-003 `out_of_scope`: closed. Cross-layer research synthesis exists and both context manifests contain only real entries.
|
||||
- F-004 `external_unverified`: retained. Real OneTalk/CRM bundle and JSONP shape, Chromium suspend/resume, production Mind/TLS, Workbench customers rendering, `/collector/v1/sync-batches` and PostgreSQL integration are not available locally; the local mock deliberately keeps the collector path at 404.
|
||||
- No new local finding; no production code was modified by the checker.
|
||||
|
||||
## Verification
|
||||
|
||||
- Contract source tests: passed 20/20.
|
||||
- Chrome extension source tests: passed 175/175.
|
||||
- Server source tests: passed 87/87 with 1 PostgreSQL test skipped because `TEST_DATABASE_URL` is unset.
|
||||
- Mind mock source TCP tests: passed 10/10 with loopback permission.
|
||||
- Root `pnpm format:check`: passed.
|
||||
- Four-package direct no-emit TypeScript checks: passed.
|
||||
- `git diff --check HEAD`: passed.
|
||||
- Schema/security/side-effect grep: passed.
|
||||
- Root `pnpm typecheck`, `pnpm build`, and `pnpm test`: blocked by package pre-hooks that write build output to `dist`; equivalent package builds and source tests passed in the implementation packages.
|
||||
- Real OneTalk/CRM/Mind/Workbench/Chromium smoke: skipped and external_unverified.
|
||||
@@ -0,0 +1,250 @@
|
||||
# Research: OneTalkContactProfile avatar contract and durable runtime ledger
|
||||
|
||||
- Query: 追踪 OneTalkContactProfile 从共享 model/decoder、MAIN 页面资料读取、page bridge envelope、Service Worker runtime/profile ledger、Bright WebSocket 到 Mind delivery 的完整数据流;核对 exact-key、URL/null、fingerprint、pending、ACK、迟到 ACK、账号/epoch 隔离和消息链路隔离的现状与头像扩展缺口。
|
||||
- Scope: internal(仓库源码、现有 docs、父任务归档研究和测试);真实 OneTalk/CRM/Mind/Workbench 行为作为 external_unverified 单独标记。
|
||||
- Date: 2026-09-01
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. 当前基线与完整数据流
|
||||
|
||||
当前工作树已经有父任务的基础联系人资料链路,但没有任何实际 avatarUrl 实现。对 apps、docs、.trellis/spec/ 和父任务归档研究做 rg 后,avatarUrl 只出现在本任务 PRD/design/implement 中;现有应用源码、测试、Mind mock/store 和 Workbench/collector 路径均没有该字段。
|
||||
|
||||
已存在的基础链路如下:
|
||||
|
||||
OneTalk MAIN __conversationListData__ / EventBus syncData
|
||||
-> profileFromConversationRow / profilesFromConversationMap
|
||||
-> page-bridge onetalk.page.profile-observed
|
||||
-> ISOLATED window/origin/decoder/方向检查 -> runtime.Port
|
||||
-> Service Worker profile envelope account fence
|
||||
-> IndexedDB onetalk_contact_profiles pending ledger
|
||||
-> ContactProfileCoordinator durable-first flush
|
||||
-> Bright contact.profile.observed
|
||||
-> server canonical plugin + sync/read authorization
|
||||
-> Mind HTTP /internal/bright/onetalk/contact-profiles
|
||||
-> contact.profile.ack
|
||||
-> requestId -> [ledger key, fingerprint] 映射
|
||||
-> 只有当前 pending fingerprint 匹配且 IDB transaction complete 才清 pending
|
||||
|
||||
代码事实:
|
||||
|
||||
- apps/onetalk-contract/src/model.ts:244-258 是当前共享 OneTalkContactProfile 唯一类型所有者,字段只有 conversationId、aliId、7 个基础资料字段、observedAtMs、profileFingerprint 和 observationStatus,没有 avatarUrl、来源或字段 provenance。
|
||||
- apps/chrome-extension/src/onetalk/main-page/contact-observer/model.ts:63-100 将会话 row 清洗成新对象;apps/chrome-extension/src/onetalk/main-page/contact-observer/entry.ts:50-75,85-101 分别消费 syncData 和 account-level snapshot。未返回原始 row。
|
||||
- apps/chrome-extension/src/onetalk/page-bridge/model.ts:43-49,235-254,365-376 已有带 channelAccountId 的 profile envelope、严格外层键和共享 profile guard;apps/chrome-extension/src/onetalk/page-bridge/isolated.ts:54-75 只做 source/origin、decode、方向检查和原样 Port 转发。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/runtime.ts:311-345,387-414 先以同一 Port 最近 hello 的账号校验 profile envelope,再执行 profile persistence/handler;apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts:56-70,81-138,164-173 负责 page/config/connection epoch、snapshot command 和 coordinator scope fence。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/storage.ts:15-22,89-103,190-192,285-350 定义 schema version 4、独立 onetalk_contact_profiles store、[channelAccountId, aliId] key 和 profile ledger record;storage.ts:835-895 实现 pending 写入及 ACK fingerprint fence。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/contact-profile-coordinator.ts:55-68,163-221,224-305 拥有 profile field list、chunk、pending flush、request map、reconnect resend 和 ACK 分流;它不进入消息 SyncEngine。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/bright-client.ts:133-151,529-560,629-660,810-819 复用同一 authenticated/scope/decode/send guard 发送 profile 和接收 ACK,没有第二条 socket 或认证链。
|
||||
- apps/server/src/mind-contact-profile.ts:5-99 以固定 Mind HTTP endpoint、显式 profile pick、response/no-response 结果和不读 response body 的 adapter 作为 Bright→Mind 边界;apps/server/src/websocket/handler.ts:735-802 负责 canonical connection、delivery、post-response authorization/policy/connection fence 和 profile ACK。
|
||||
|
||||
### 2. 共享 contract/decoder:exact-key 现状与 avatar 缺口
|
||||
|
||||
现有 profile wire contract 已是 fail-closed 的精确形状:
|
||||
|
||||
- apps/onetalk-contract/src/decoder.ts:131-144 的 PROFILE_KEYS 与 decoder.ts:173-191 的 isOneTalkContactProfile 拒绝未知 profile 字段,并要求 aliId/conversationId/fingerprint 非空、时间为非负 safe integer、状态为 confirmed|partial。
|
||||
- decoder.ts:164-170 的 hasExactProfileFrameKeys 约束 profile frame 顶层只允许 base frame keys 和 sendRequestId;decoder.ts:376-397 约束 profile payload 只有 profiles 或 ACK 的 status/profileCount,批次为 1..100,完整 frame 不超过 256 KiB。
|
||||
- decoder.ts:286-315 已将 observed/ack 都限制为 plugin 方向;apps/onetalk-contract/test/contract.test.ts:178-298 覆盖敏感字段、顶层额外字段、错误方向、空批次和 frame size。当前 contract source test 为 20/20 通过。
|
||||
|
||||
avatar 的缺口不是局部类型增补:
|
||||
|
||||
1. avatarUrl 必须进入 model.ts:245-258、decoder.ts:131-191、所有 profile 构造器和所有 fixture;如果沿现有 exact-key 规则作为必填字段,旧的 12-field profile frame 会被拒绝。
|
||||
2. 当前 decoder 只对基础字段做 string|null 类型判断;没有 URL-like validator,因此 "a"、空白字符串、javascript:、data:、blob: 等不会按头像语义被拒绝。URL 规范化和协议(尤其相对 URL 是否允许)在仓库 docs/测试中没有已验证答案。
|
||||
3. 头像“已确认但无有效 URL”必须输出 avatarUrl: null,而“CRM 没有确认头像”必须保持未覆盖。这两个状态不能靠最终共享 profile 的一个 null 独自表达,必须在 MAIN 内部 source-local observation/merge 模型中区分,最终跨边界对象再只保留 avatarUrl。
|
||||
4. profileFingerprint 当前由 MAIN 的 contact-observer/model.ts:10-20,42-55 固定字段数组计算;头像若不加入该数组,头像单独变化不会产生新 fingerprint、不会进入 pending,也不会上传。加入后应明确 fingerprint version/迁移语义,因为即使所有旧 profile 的 avatar 都是 null,字段数组变化也会造成一次全量 fingerprint 变化。
|
||||
|
||||
### 3. MAIN SDK 来源、身份和 CRM JSONP 现状
|
||||
|
||||
#### 已验证的 SDK/profile 入口
|
||||
|
||||
- apps/chrome-extension/src/onetalk/main-page/contact-observer/model.ts:57-61,64-100 当前只从 row.contact 优先、row 顶层兜底读取基础字段;profilesFromConversationMap 在 :102-111 只消费 map values。
|
||||
- snapshot 读取 pageWindow.__conversationListData__(contact-observer/entry.ts:85-101),增量订阅 EventBus.on("im-conversation-list:syncData", ...)(:50-77),两者目前共用 profilesFromConversationMap。
|
||||
- contact-observer/model.ts:34-40 只按 isGroup/groupId/groupType/conversationType 排除群聊;当前没有调用 getConversationListByPagination()。历史 docs 只证明该 SDK 方法/内部实现曾存在,不能作为当前调用契约。
|
||||
- apps/chrome-extension/src/onetalk/main-page/page-context.ts:24-44 先读 currentUserAccountId,再读 IcbuIM.UserUtil.currentUser.accountId;page-context.ts:11-22 单独读取 URL activeAccountId,但注释和调用关系都明确它是选中对端,不能作为 channelAccountId fallback。
|
||||
- 当前 profile observer 用会话 row 的 cid 作为 conversationId,但不读取 selected DOM data-cid;selected DOM 的零/一/多选择判定只在 page-context.ts:46-71 被 hello/send/sync 路由使用。
|
||||
|
||||
#### CRM 被动观察是完整缺口
|
||||
|
||||
仓库中不存在 queryCustomerInfo、queryCustomerTag、buyerInfo、JSONP callback hook 或 CRM script observer 的生产代码/测试。apps/chrome-extension/src/onetalk/main-page/model.ts:5-16 的 OneTalkPageWindow 只有 querySelectorAll/addEventListener 和 OneTalk globals,没有 MutationObserver、script source/属性读取、页面 callback 注册表或 callback wrapper 能力。
|
||||
|
||||
因此,R2 所要求的行为目前均未被本地实现或证明:
|
||||
|
||||
- 只匹配 queryCustomerInfo/queryCustomerTag 的新增/已有 script;
|
||||
- 只在页面自然加载且已有 callback 被调用时读取 buyerInfo;
|
||||
- 只提取 avatarUrl、fullPortrait、profilePicUrl、profilePictureUrl、imgUrl、portraitUrl、headImg、headerSmallPath;
|
||||
- 不主动发 CRM 请求,不创建 enrichment job,不主动打开客户卡;
|
||||
- 用当前真实登录账号和唯一 selected data-cid 关联 callback;身份、账号、cid 任一不明确时 fail closed;
|
||||
- 不记录/转发 callback 原始参数、secReqToken、ctoken、_tb_token_、Cookie、加密 ID 或完整 JSONP response。
|
||||
|
||||
建议的 locus 是 main-page/contact-observer/ 内部新增 source-local CRM observer/merge 模块,而不是 page bridge 或 Service Worker。page-script-entry.ts:19-37 仍只负责组装 observer、profile command、消息 observer 和 history sync;CRM callback cleanup 应挂在 observer dispose/pagehide 边界。由于当前没有 callback 形状或 avatar URL 协议的本地证据,hook 的实际 callback naming/chaining 和相对 URL 规则必须标为 external_unverified,不能由源码猜测。
|
||||
|
||||
### 4. Page bridge 与 Service Worker runtime
|
||||
|
||||
#### Page bridge 当前可复用的安全边界
|
||||
|
||||
- page-bridge/model.ts:235-254 对 profile envelope 要求外层键精确为 channelAccountId,profiles,source,type,version,每个 profile 使用共享 isOneTalkContactProfile;这会自动把新增 avatarUrl 的 exact-key 要求延伸到页面桥。
|
||||
- page-bridge/main.ts:130-143 在当前 page origin 上发送清洗后的 profile;page-bridge/main.ts:45-57 发送前再次 decode。page-bridge/isolated.ts:54-75 不解释业务字段,也不拥有 retry/queue/storage。
|
||||
- main-page/page-script-entry.ts:22-30 已把 profile command handler 与 history command handler 分开;profile 不会进入 message observer。
|
||||
|
||||
头像扩展应保持上述无状态桥:SDK/CRM 合并、URL 清洗和 provenance 在 MAIN 完成;envelope 只携带最终 OneTalkContactProfile[] 和真实登录账号。不要把 CRM raw row、callback args、source token 或 img 读取结果加进 envelope,也不要把 source-local avatarConfirmed 作为跨边界第二套 contract。
|
||||
|
||||
#### Runtime 当前 account/epoch fence
|
||||
|
||||
- runtime.ts:311-345 拒绝 profile envelope 账号与同 Port 最近 hello 账号不一致的消息;page-runtime-host.ts:164-173 再核对 coordinator scope 后才调用 observe。
|
||||
- runtime.ts:243-264 在 page identity 改变时先收敛 pending commands 再替换 identity;runtime.ts:556-620 对 account-level snapshot 只选择唯一同账号页面,零页面/多页面分别 fail closed。
|
||||
- page-runtime-host.ts:56-70,81-110,113-138,201-224 通过 configuration/connection/page epoch 检查 snapshot callback;sync-controller.ts:128-170 把 session revision 传入 replay。configured-sync-session.ts:137-145,224-245 替换配置时 dispose 旧 engine/profile/bright 状态并阻止旧 revision 的 callback。
|
||||
- 父任务归档 revalidation 报告记录:重复 hello、旧账号消息、旧 ACK、重连、事务 abort/error、latest-wins、页面/config epoch 和 snapshot non-blocking focused checks 已通过;当前工作树的 runtime source tests 也覆盖这些基线。
|
||||
|
||||
因此账号/epoch 隔离可以复用,不需要 avatar 专属 epoch 或第二个 coordinator。新 CRM 事件必须在 MAIN 先做真实登录账号和 selected data-cid fence;传到 runtime 后继续依赖现有 envelope/hello/config fence。若实现者修改 profile envelope,必须保留现有 message envelope 路径不变。
|
||||
|
||||
### 5. Profile ledger、pending、ACK、迟到 ACK
|
||||
|
||||
当前 durable 语义是可复用且有 focused evidence:
|
||||
|
||||
- storage.ts:15-22,285-350 在 DB version 4 只新增 onetalk_contact_profiles,不删除/重建 message、checkpoint、candidate、anomaly stores;profile key contactProfileKey()(:190-192)固定为 JSON.stringify([channelAccountId, aliId])。
|
||||
- storage.ts:773-790 只保存最新清洗 profile、fingerprint、observed time 和既有 ACK 状态。putPendingProfile()(:835-868)同 fingerprint 且无 pending 时跳过;pending fingerprint/conversation 不同时替换最新 profile;写入通过 readwrite transaction 完成后才返回。
|
||||
- contact-profile-coordinator.ts:163-221 先从该账号 ledger 列 pending,再按数量/256 KiB 分 chunk,调用 Bright sendContactProfiles;observe()(:224-250)先等待 putPendingProfile,之后才 flush。
|
||||
- contact-profile-coordinator.ts:252-295 只处理 contact.profile.ack,以 requestId 找到 [ledger key, aliId, fingerprint];ACK count 不匹配则不确认;storage.ts:870-895 重新读取 ledger,只有当前 pending fingerprint 等于 ACK fingerprint 才写入 lastUploadedFingerprint、删除 pending 并等待 transaction completion。
|
||||
- Bright offline/non-auth/page disconnect 只清内存 request map(contact-profile-coordinator.ts:298-321),不清 durable pending;重新 authenticated 时从 ledger 重建发送。未知 request、旧 request map 已清或 fingerprint 已被替换的 ACK 都不会确认新值。
|
||||
|
||||
头像要沿这条状态机走:URL 改变、有效 URL→null、null→有效 URL 都必须由 fingerprint 变化进入 pending;同 fingerprint 不重复上传;迟到 ACK 不能删除新 avatar pending。不要为 CRM 或头像新增 store、timer retry、ACK 类型或 Bright queue。
|
||||
|
||||
当前 durable 方面的明确缺口只有头像字段映射:
|
||||
|
||||
- PROFILE_FIELD_NAMES(contact-profile-coordinator.ts:55-68)没有 avatarUrl,因此诊断 field list 不完整;
|
||||
- profile 类型一旦增加字段,profileRecordFor/pending 会随对象 spread 保留它,但必须同步更新 fixtures、fingerprint 和 decoder;
|
||||
- storage record 没有 source/provenance,故 CRM“未确认”不能在 ledger 层表达为 null 覆盖,必须在 MAIN merge 后再写最终 profile;
|
||||
- ACK frame 只带 requestId/count,靠当前内存 request map + fingerprint fence,不应在 avatar task 中改为按 URL/CRM callback 识别 ACK。
|
||||
|
||||
### 6. Bright client、server delivery、Mind mock/Workbench
|
||||
|
||||
#### Bright/server 当前事实
|
||||
|
||||
- bright-client.ts:810-819 的 sendContactProfiles 用 makeContactProfilesFrame(:348-356)进入通用 sendFrame,因此会复用 authenticated、socket-open、scope、decoder 和 send error boundary。无 avatar 专用旁路。
|
||||
- apps/server/src/mind-contact-profile.ts:25-39,59-79 的 MindContactProfile/profileForMind 是 HTTP 外部边界的显式 whitelist,目前完整保留 12 个 profile 字段,但不包含 avatarUrl。它不会 spread runtime profile,也不传 device/Mind scope/Cookie/token/raw row。
|
||||
- mind-contact-profile.ts:81-99 只等待 fetch 返回 response headers;100/200/204/302/400/404/500 等 response class 都是 delivered,不读 body;throw/timeout 是 no_response。handler.ts:735-802 只有 delivery 成功且 post-response authorization、policy epoch、commit guard、canonical connection 仍有效时才发 ACK。
|
||||
- apps/server/src/app.ts:79-97 和 websocket/index.ts:45-58,99-118 以可注入 dependency 组合 delivery;apps/server/src/database/schema/onetalk.ts:45-203、src/onetalk/ 和 migrations 没有 profile 表/列或 profile message projection。profile 分支不调用 OneTalkService/repository。
|
||||
- apps/server/src/dev-entry.ts:51-60 的开发 sink 会 JSON.stringify 已解码的 profile upload event;加入 avatar 后 URL 会进入 development sink。该 sink 不是 safe diagnostics,且生产 entry.ts:15-25 默认不注册;实现时仍需明确 URL 是否允许进入开发日志,不能让 callback raw/token 随事件进入日志。
|
||||
|
||||
#### Mind mock/Workbench 是未找到边界
|
||||
|
||||
- apps/mind-http-mock/src/model.ts:1-33 只有授权/权限/session model;server.ts:26-56 只提供 binding/session 两个 POST 路径,没有 profile receiver、customer store、customers[].avatarUrl 或 null-clear 语义。
|
||||
- 全仓库没有 Workbench UI、/collector/v1/sync-batches、customers[] 或 tm-binding-avatar 实现;因此本仓库无法证明 customers[].avatarUrl 展示、缺失/null 占位清除、collector upsert、CRM 手工字段保护或 tm-binding-avatar 渠道 logo 语义。不要在本任务中凭空造第二个业务 collector/UI 链路;这些验收应归 external_unverified,除非后续提供对应仓库/endpoint。
|
||||
|
||||
### 7. 消息链路不受影响的现状
|
||||
|
||||
当前 profile 与 message 在每一层都是独立分支:
|
||||
|
||||
- 页面:page-script-entry.ts:32-37 的 message observer/history sync 与 :22-30 的 profile observer/command 分离。
|
||||
- Runtime:runtime.ts:284-309 的 message persistence 与 :311-345 的 profile persistence 使用不同 callback;profile account mismatch 不会调用 message handler。
|
||||
- Service Worker:profile coordinator 独立于 sync-engine.ts;后者的 message ACK/checkpoint/anchor 代码没有 contact.profile 引用。
|
||||
- Server:handler.ts:735-802 profile 分支在自身 delivery/ACK 后直接 return,不进入 conversation/message branches;schema/repository 没有 profile projection。
|
||||
- 当前 source tests 对扩展 profile+message/send/sync 相关链路共 78/78 通过,server profile+既有 WebSocket 消息回归共 35/35 通过。该证据证明基线隔离存在,但不证明尚未实现的 avatar/CRM 新代码。
|
||||
|
||||
## Invariants and Acceptance Probes
|
||||
|
||||
### Invariant owner
|
||||
|
||||
主不变量是:对每个 channelAccountId + aliId,最终跨边界只允许一个当前页面产生的、严格白名单的 OneTalkContactProfile;SDK 与 CRM 只能在 MAIN 的 source-local merge owner 内合并,CRM 明确确认的头像可覆盖 SDK 头像,CRM 未确认不得覆盖,确认无有效 URL 才能输出 avatarUrl: null。该 profile 的最新 fingerprint 在对应 Bright ACK 且本地 IDB transaction complete 前必须保持 pending;迟到旧 ACK 不得清除新 fingerprint。Bright/Mind 只接收已授权 binding 下的清洗 profile;message/candidate/checkpoint/anomaly/send 三态完全不受 profile 事件影响。
|
||||
|
||||
建议 owner:
|
||||
|
||||
1. apps/onetalk-contract/src/model.ts + decoder.ts:共享字段、exact-key、frame direction/scope/size 和 URL/null contract。
|
||||
2. apps/chrome-extension/src/onetalk/main-page/contact-observer/:SDK/CRM source-local records、URL validator 的调用、data-cid/登录账号 fail-closed、确定性 merge 和 fingerprint 输入。
|
||||
3. page-bridge/model.ts/main.ts/isolated.ts:只拥有 profile envelope、source/origin/方向转发,不解释 CRM。
|
||||
4. service-worker/runtime.ts/page-runtime-host.ts:Port/account/page/config epoch 与 durable-first dispatch;storage.ts + contact-profile-coordinator.ts:profile ledger、pending、chunk、ACK/reconnect。
|
||||
5. bright-client.ts:复用现有 transport guard;mind-contact-profile.ts:Mind body whitelist/response semantics;websocket/handler.ts:canonical authorization and post-response fence。
|
||||
6. Mind/Workbench(仓库外):最终 customer upsert、manual-field policy、customers[].avatarUrl null-clear 和 img placeholder。
|
||||
|
||||
### Sources of truth and async/side-effect boundaries
|
||||
|
||||
| 阶段 | source of truth / snapshot | await 与 mutation | 不可逆副作用 / rollback boundary |
|
||||
| --- | --- | --- | --- |
|
||||
| MAIN SDK | __conversationListData__ + im-conversation-list:syncData;CRM 仅页面自然 JSONP callback | 读取 map/script/callback、内部 source merge;pagehide dispose | 只构造新白名单 profile;不读 img、不下载、不发 CRM 请求;无法确认账号/cid 时丢弃 |
|
||||
| Page bridge | 当前 page origin、合法 profile envelope | decode/方向检查无 durable await | window.postMessage/Port 转发;bridge 不排队、不重试、不持久化 |
|
||||
| SW runtime | 同 Port 最近 hello + 当前 configured account/epoch | profile persistence await 后才通知上层;host snapshot 在多个 await 间检查 token | 只把通过 account fence 的 profile 交给 coordinator;旧 page/config callback no-op |
|
||||
| Profile ledger | IDB record key [channelAccountId, aliId],当前 pending fingerprint | putPendingProfile transaction;ACK markProfileUploaded transaction | pending 写入后才允许 Bright send;事务 abort/error 不确认;新 fingerprint 替换旧 pending |
|
||||
| Bright | authenticated plugin socket/scope | sendFrame decode/send;socket identity 丢弃旧 socket inbound | wire contact.profile.observed;send false 不清 pending;reconnect 从 durable pending 重建 |
|
||||
| Server auth/delivery | canonical plugin connection + current authorization/policy epoch | authorization await、Mind fetch await、post-response reauthorize/commit guard | Mind HTTP 请求是不可逆 side effect;response 已到但 fence 失效时不发 ACK,不能声称回滚 |
|
||||
| ACK | requestId map snapshot + current ledger fingerprint | ACK handler读取当前 ledger并等待 IDB completion | 只确认匹配 fingerprint;未知/迟到/数量不匹配 no-op |
|
||||
|
||||
### Executable acceptance probes
|
||||
|
||||
1. **Contract exact-key/URL matrix**:增加 avatarUrl 有效 URL、null、空白、"a"、javascript:、data:、blob:、非字符串、额外键和敏感顶层键;断言稳定 invalid_message、错误不回显 secret;验证 profile frame 完整字节限制包含 avatar。明确测试旧 12-field frame 在 required exact-key 方案下的兼容策略。
|
||||
2. **SDK extraction matrix**:同一 row 分别只提供 contact.fullPortrait、row fullPortrait、row avatarUrl、row portraitUrl,验证固定优先级;候选无效时继续检查下一候选;snapshot 与 syncData 必须经过同一 extractor;群聊、缺 cid/aliId、敏感 row、无 avatar 与 avatar-only change 分别断言。
|
||||
3. **CRM passive JSONP matrix**:匹配/不匹配 script URL;已有 callback 与后注册 callback;callback 参数含 token/raw row;buyerInfo 每个头像白名单字段逐一测试;无 selected cid、多个 selected cid、登录账号缺失/改变、cid 与 buyer identity 不匹配时必须无 profile emit;instrument fetch/XHR,证明 observer 不主动发请求;pagehide 后 callback/script observer 不再产生事件。
|
||||
4. **Merge/null matrix**:SDK URL + CRM URL → CRM URL;SDK URL + CRM 未提供 → 保留 SDK URL;SDK URL + CRM 明确缺失/无效 → avatarUrl:null;不同 aliId 不合并;CRM 非头像字段仅在明确存在时覆盖;卖家头像、activeAccountId、tm-binding-avatar 永不进入客户头像。
|
||||
5. **Bridge/account/epoch matrix**:合法 envelope 穿过 MAIN→ISOLATED→Port;外层额外键、wrong origin/source/direction、envelope account 与 hello/config 不一致均丢弃;重复 hello 不重复 snapshot;延迟 old hello/config/page callback 在切账号后不能调用新 coordinator/engine 或发送旧账号 snapshot。
|
||||
6. **Ledger matrix**:URL v1→v2、URL→null、null→URL;same fingerprint skip;pending latest-wins;ACK count mismatch/unknown request/旧 fingerprint;ACK 事务 complete 前不返回 delivered,abort/error 后 pending 保留;Bright 断线、SW restart、page disconnect 后从同账号 durable pending 重发;A/B 账号 key 和 page/config epoch 不串。
|
||||
7. **Bright/server matrix**:avatar URL/null 出现在 wire 和 Mind body;body 只含 channelAccountId + binding + profiles 及批准字段;runtime profile 注入 raw row/token/Cookie/device/Mind scope 不外泄;HTTP 1xx–5xx response ACK、timeout/network/no response 不 ACK;revoke/version/read/pause/canonical replacement 在 delivery await 中均无迟到 ACK;不调用 service/repository/DB。
|
||||
8. **Message regression matrix**:profile success/failure/IDB abort/Bright reconnect 与 message.observed ACK、anchor/checkpoint、history/incremental/live、send confirmed_sent/delivery_unknown、binding authorization 并行运行,断言 message stores、message ACK、send three-state 和 Bright DB/schema 无 avatar/profile side effect。
|
||||
9. **Mind/Workbench boundary probe**:当前仓库只能运行 adapter/WS fake;真实 /collector/v1/sync-batches、customers[].avatarUrl 保存/清除、Workbench img src 与 placeholder、tm-binding-avatar 不变需外部环境或对应代码库,统一标为 external_unverified。
|
||||
|
||||
### Feasibility / locus of control
|
||||
|
||||
仓库可以证明:共享字段/exact-key/frame direction、MAIN whitelist 构造、现有 SDK snapshot/syncData 入口、page bridge source/origin/账号 envelope、SW durable-first、[channelAccountId, aliId] ledger、fingerprint/pending/ACK/重连/迟到 ACK、Bright body whitelist、server authorization/async fence、无 Bright DB profile persistence,以及旧消息链路回归。
|
||||
|
||||
仓库不能证明:当前生产 OneTalk bundle 是否仍提供头像字段或 CRM JSONP callback 形状;头像 URL 实际协议/重定向/CDN 规则;真实 Mind profile endpoint 是否存在、接受 binding/body/null 语义、客户关联/幂等/manual-field policy;Workbench/collector 的 customers[].avatarUrl upsert/render/clear;真实 Chromium 的 callback chain、Service Worker suspend/resume、TLS/Cookie/生产授权和 PostgreSQL integration。
|
||||
|
||||
### Focused validation executed
|
||||
|
||||
- node --experimental-strip-types --test apps/onetalk-contract/test/contract.test.ts: passed, 20/20。
|
||||
- node --experimental-strip-types --test apps/chrome-extension/test/onetalk-contact-profile-observer.test.js apps/chrome-extension/test/onetalk-page-bridge.test.js apps/chrome-extension/test/onetalk-service-worker-runtime.test.js apps/chrome-extension/test/onetalk-contact-profile-coordinator.test.js apps/chrome-extension/test/onetalk-contact-profile-storage.test.js apps/chrome-extension/test/onetalk-bright-client.test.js apps/chrome-extension/test/onetalk-configured-sync-session.test.js apps/chrome-extension/test/onetalk-sync-engine.test.js apps/chrome-extension/test/onetalk-send-page.test.js: passed, 78/78。
|
||||
- node --experimental-strip-types --test apps/server/test/mind-contact-profile.test.ts apps/server/test/onetalk-profile-websocket.test.ts apps/server/test/onetalk-websocket.test.ts: passed, 35/35。
|
||||
- ./node_modules/.bin/tsc --noEmit -p apps/onetalk-contract/tsconfig.json && ./node_modules/.bin/tsc --noEmit -p apps/chrome-extension/tsconfig.json && ./node_modules/.bin/tsc --noEmit -p apps/server/tsconfig.json && ./node_modules/.bin/tsc --noEmit -p apps/mind-http-mock/tsconfig.json: passed。
|
||||
- 未运行 build/full package tests/format check;研究范围要求只写研究文件,避免触发 package build 生成 dist。未使用真实 Chromium、CRM JSONP、Mind/Workbench、collector 或 PostgreSQL integration。
|
||||
|
||||
## Files found
|
||||
|
||||
- apps/onetalk-contract/src/model.ts:244-258 — 共享 OneTalkContactProfile、profile frame 类型与公共协议模型。
|
||||
- apps/onetalk-contract/src/decoder.ts:131-191,286-397,452-475 — profile exact-key、类型、方向、批次和 frame-size decoder。
|
||||
- apps/onetalk-contract/test/contract.test.ts:163-298 — profile shape、敏感字段、方向、空批次和大小回归。
|
||||
- apps/chrome-extension/src/onetalk/main-page/contact-observer/model.ts:10-111 — SDK row whitelist、单聊过滤、fingerprint 和 snapshot map extractor。
|
||||
- apps/chrome-extension/src/onetalk/main-page/contact-observer/entry.ts:29-105 — snapshot/syncData observer、登录账号读取、去重和 pagehide。
|
||||
- apps/chrome-extension/src/onetalk/main-page/page-context.ts:11-71 — URL selected account、真实登录账号和 selected data-cid 读取边界。
|
||||
- apps/chrome-extension/src/onetalk/main-page/model.ts:5-16 — 页面运行时可访问能力;没有 CRM script/callback observer surface。
|
||||
- apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts:19-37 — profile/message/history MAIN 组装入口。
|
||||
- apps/chrome-extension/src/onetalk/page-bridge/model.ts:43-49,235-254,365-376 — profile page envelope decoder/constructor。
|
||||
- apps/chrome-extension/src/onetalk/page-bridge/main.ts:130-143 and isolated.ts:41-88 — profile postMessage/Port 无状态转发。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/runtime.ts:243-345,387-450,556-620 — page identity、profile durable dispatch 和 exact route。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/page-runtime-host.ts:56-138,201-229 — page/config/connection epoch、snapshot non-blocking 与 profile scope。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/configured-sync-session.ts:125-280 and sync-controller.ts:128-170 — session revision、profile lifecycle composition 和 dispose/replay。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/storage.ts:15-22,89-103,154-192,285-350,773-899 — schema/store/key/ledger transaction implementation。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/contact-profile-coordinator.ts:55-68,163-323 — pending/chunk/request map/ACK/reconnect coordinator。
|
||||
- apps/chrome-extension/src/onetalk/service-worker/bright-client.ts:133-161,629-682,810-881 — shared Bright transport and profile helper。
|
||||
- apps/server/src/mind-contact-profile.ts:5-99 — explicit Mind HTTP profile body and response/no-response adapter。
|
||||
- apps/server/src/websocket/handler.ts:253-284,735-802 — profile authorization, delivery, post-response fence and ACK。
|
||||
- apps/server/src/app.ts:30-107, apps/server/src/websocket/index.ts:36-169 — delivery dependency composition。
|
||||
- apps/server/src/database/schema/onetalk.ts:45-203, apps/server/src/onetalk/ — Bright message/conversation/anomaly-only persistence; no profile store。
|
||||
- apps/mind-http-mock/src/model.ts:1-33, server.ts:26-60 — authorization-only mock; no profile/customer store。
|
||||
- apps/chrome-extension/test/onetalk-contact-profile-observer.test.js, ...coordinator.test.js, ...storage.test.js, ...service-worker-runtime.test.js — existing profile observer/ledger/runtime regressions。
|
||||
- apps/server/test/mind-contact-profile.test.ts, onetalk-profile-websocket.test.ts, onetalk-websocket.test.ts — adapter, profile ACK fence and message regression tests。
|
||||
- docs/onetalk-customer-profile-fetch.md:1-149,509-629,680-730 — dated historical OneTalk global snapshot/account/ID evidence; no verified avatar/CRM JSONP contract。
|
||||
- .trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md, page-bridge.md, runtime-sync.md, durable-sync.md — current profile/page/runtime/durable contracts, all pre-avatar。
|
||||
- .trellis/spec/server/backend/mind-contact-profile.md — current Mind profile adapter contract, explicit 12-field pick, no avatar。
|
||||
- .trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/{onetalk-customer-profile-cross-layer,recheck-contract-page,recheck-ledger-runtime,recheck-server-validation,check-*-revalidation}.md — parent baseline design, durable/ACK/account/epoch findings and final verification status。
|
||||
|
||||
## Compatibility and change-risk notes
|
||||
|
||||
- Required avatarUrl under the current exact-key decoder is a wire compatibility change for any mixed old/new plugin, Bright or test fixture. Decide whether protocol version 2 is intentionally rolled as a breaking profile shape, or whether a compatibility window is required; do not silently make the field optional while claiming explicit null semantics.
|
||||
- Adding avatar to the current v1- fingerprint input changes every existing profile fingerprint once, even when avatar is null. This can intentionally cause a one-time re-upload, but it must be documented/tested rather than treated as an incidental retry.
|
||||
- The existing generic readProfileField(contact,row,key) uses contact[key] ?? row[key] (model.ts:57-61); for avatar/CRM semantics, an explicit invalid/null source result must not accidentally fall through to a lower-priority source when null means confirmed clear. Use source-specific candidate state and fixed priority.
|
||||
- There is no local evidence for avatar URL scheme, relative paths, CDN redirects, URL length, or whether a URL may be persisted/rendered as raw img src. Keep that boundary external_unverified until a real page/Mind decision is available.
|
||||
- No local Workbench/collector/Mind customer store exists. ACs about customers[].avatarUrl, null-clear and tm-binding-avatar cannot be proven or implemented coherently in this repository without a new external contract; do not create a second business source of truth.
|
||||
- onContactProfileUpload is a development sink that currently logs decoded profiles (dev-entry.ts:51-60). Decide explicitly whether sanitized avatar URLs are acceptable in development logs; raw CRM callback args/tokens/raw rows must remain forbidden.
|
||||
|
||||
## Related specs
|
||||
|
||||
- .trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md — current profile field, page source, ledger, ACK and sensitive-field contract; must be extended only after avatar evidence/decisions.
|
||||
- .trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md — profile envelope account fence and exact page routing.
|
||||
- .trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md — profile flow, ownership and account/epoch invariants.
|
||||
- .trellis/spec/chrome-extension/frontend/onetalk/durable-sync.md — independent profile ledger and commit-before-ACK semantics.
|
||||
- .trellis/spec/server/backend/mind-contact-profile.md — Bright→Mind explicit pick, response/no-response and async fence.
|
||||
- .trellis/spec/project/missing-values.md, module-ownership.md, guides/cross-layer-thinking-guide.md — null/default ownership, canonical type owner and cross-layer boundary rules.
|
||||
|
||||
## Caveats / Not Found
|
||||
|
||||
- No application, test, spec, docs or Git state was modified by this research; only this file is written.
|
||||
- No production code currently reads or validates avatar URLs, observes CRM JSONP, stores customers[].avatarUrl, renders Workbench avatars, or implements /collector/v1/sync-batches.
|
||||
- The dated docs/onetalk-customer-profile-fetch.md proves the historical global conversation map and logged-in-account distinction, but contains no verified avatar field samples or CRM callback shape. The task PRD’s old TradeBridge observations are requirements context, not repository/runtime evidence.
|
||||
- Focused source tests and direct no-emit TypeScript checks passed as listed above; package build, format, real browser/CRM/Mind/Workbench and PostgreSQL integration were not run.
|
||||
- Parent revalidation reports say baseline profile account/epoch/ACK fences are fixed, but those reports do not validate the new avatar field, URL validator, CRM observer, source merge or external customer store.
|
||||
@@ -0,0 +1,155 @@
|
||||
# Research: OneTalk 页面头像观察边界
|
||||
|
||||
- Query: 核对 OneTalk conversation SDK/map 的头像字段与优先级、snapshot 与 `syncData` 入口、单聊/群聊和真实登录账号语义、唯一 selected `data-cid` 来源、CRM script/JSONP 被动观察能力、页面接入点及跨层安全边界。
|
||||
- Scope: mixed(当前 worktree 源码、现有 docs、父任务归档研究和相关测试;未进行浏览器或生产联调)
|
||||
- Date: 2026-09-01
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. 头像字段证据:目标优先级尚未被当前仓库真实验证
|
||||
|
||||
- 当前任务 PRD R1 给出的目标顺序是 `contact.fullPortrait` → 会话顶层 `fullPortrait` → `avatarUrl` → `portraitUrl`(`.trellis/tasks/09-01-onetalk-customer-avatar-profile/prd.md` 的 R1)。这是本任务需求,不是当前 worktree 中已有的运行时代码证据。
|
||||
- 当前 profile 提取器 `profileFromConversationRow()` 只读取 `aliId/accountId/loginId/name/companyName/complianceCountryCode|countryCode/currentTimeZone/serviceType`;`contact` 优先、row 顶层兜底的入口在 `apps/chrome-extension/src/onetalk/main-page/contact-observer/model.ts:57-92`,指纹字段固定在 `:10-20`,没有 `avatarUrl/fullPortrait/portraitUrl`。
|
||||
- 当前共享 profile 类型也没有头像字段:`apps/onetalk-contract/src/model.ts:244-258`;decoder 的严格 profile key 集合在 `apps/onetalk-contract/src/decoder.ts:131-190`,服务端 Mind pick 在 `apps/server/src/mind-contact-profile.ts:25-72`。因此头像尚未进入页面 envelope、ledger 或 Bright→Mind body。
|
||||
- 现有历史探查 doc 只证明详情 HTTP 返回结果包含 `fullPortrait`(`docs/onetalk-customer-profile-fetch.md:268-292`),并且明确该详情刷新路径需要页面内加密 ID/chat token(`:249-265`、`:318-335`),当前第一阶段禁止调用或转发这些字段(`:7-9`、`:678-700`)。该证据不能证明会话 map 中 `contact.fullPortrait`、顶层 `fullPortrait`、`avatarUrl` 或 `portraitUrl` 的实际存在和优先级。
|
||||
- 仓库源码、测试、现有 docs 与父任务归档研究中未找到 `avatarUrl`、`portraitUrl`、CRM JSONP 字段或真实会话 map 头像样本;历史 doc 中的 `fullPortrait` 仅出现于详情返回字段和明确禁止跨边界的示例。
|
||||
|
||||
结论:实现前必须把上面的优先级当作“待真实页面确认的输入契约”。在没有 live page evidence 时,安全实现只能按目标顺序做白名单、URL 校验和 fail-closed;不能声称这些字段已由仓库或当前生产 bundle 验证。
|
||||
|
||||
### 2. snapshot 与 `syncData` 已共用同一 map 提取入口
|
||||
|
||||
当前 profile observer 的数据流是:
|
||||
|
||||
```text
|
||||
account-level onetalk.contact.snapshot
|
||||
-> observer.snapshot()
|
||||
-> pageWindow.__conversationListData__
|
||||
-> profilesFromConversationMap()
|
||||
-> sink(profiles, channelAccountId)
|
||||
|
||||
EventBus.on("im-conversation-list:syncData", consumeUpdates)
|
||||
-> profilesFromConversationMap(value)
|
||||
-> fingerprint diff
|
||||
-> sink(changed, channelAccountId)
|
||||
```
|
||||
|
||||
源码证据如下:
|
||||
|
||||
- `profilesFromConversationMap()` 对 map 做值遍历,并统一调用 `profileFromConversationRow()`,`apps/chrome-extension/src/onetalk/main-page/contact-observer/model.ts:102-110`。
|
||||
- `syncData` 订阅和更新消费在 `apps/chrome-extension/src/onetalk/main-page/contact-observer/entry.ts:50-77`;它每次重新读取登录账号、清空/隔离 `seen`,再按 `profileFingerprint` 去重(`:51-74`)。
|
||||
- snapshot 在 `apps/chrome-extension/src/onetalk/main-page/contact-observer/entry.ts:84-101` 读取 `pageWindow.__conversationListData__`,调用同一个 `profilesFromConversationMap()`(`:95-100`)。首次 snapshot 是 account-level command 触发,而不是安装事件直接读取;入口组合见 `apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts:18-37`,命令见 `contact-observer/page-command.ts:7-18`。
|
||||
- 历史 doc 的运行时观察也记录了 `__conversationListData__`、`__conversationListMapFullData__`、`__conversationListFullData__`(`docs/onetalk-customer-profile-fetch.md:87-111`),并记录页面通过 `EventBus.publish("im-conversation-list:syncData", map)` 推送 map(`:103-109`)。当前代码只声明/读取 `__conversationListData__`,没有把 aliases 当第二来源。
|
||||
|
||||
建议:头像 SDK/map 候选读取必须进入 `profileFromConversationRow()` 或其明确的 source-local helper,使 snapshot 和 `syncData` 自动共用;不要在 snapshot 和 callback 各写一套头像优先级。CRM 观察属于不同来源,应先构造 source-local CRM record,再在同一个 profile merge owner 合并,不能把原始 callback 参数直接送入 `profilesFromConversationMap()`。
|
||||
|
||||
### 3. 单聊/群聊与登录账号语义
|
||||
|
||||
- 当前 map observer 的群聊排除是本地启发式:`isGroupConversation()` 在 `apps/chrome-extension/src/onetalk/main-page/contact-observer/model.ts:34-40` 识别 `isGroup === true`、存在 `groupId/groupType` 或 `conversationType` 为 `group/群聊`;命中即由 `profileFromConversationRow():68` 丢弃。仓库没有真实 map fixture 证明 OneTalk 当前 bundle 使用哪一个标记,因此该判断仍需生产验证。
|
||||
- 历史同步 SDK normalizer 对真实分页 envelope 的单/群聊结构有本地证据:`normalizeConversationEntry()` 在 `apps/chrome-extension/src/onetalk/main-page/current-conversation-history/model.ts:333-360` 将 `groupChatUserConversation/groupChatConversation` 分类为 skipped;单聊继续走 `normalizeConversation()`。`normalizeConversationPage()` 接受 `body.userConvs` 和 `code === 200`(`:363-385`)。测试 fixture 验证单聊成功、群聊 skipped 且不调用历史 API,`apps/chrome-extension/test/onetalk-current-conversation-history.test.js:500-544`。
|
||||
- 但 profile observer 当前不调用 conversation SDK 分页;SDK 历史读取入口在 `apps/chrome-extension/src/onetalk/main-page/current-conversation-history/sdk.ts:17-52`,profile 只读已加载 map。因此不能把历史分页 normalizer 的字段或 `pairFirst/pairSecond` 推断为 profile avatar 来源。
|
||||
- 真实登录账号由 `readChannelAccountId()` 读取 `pageWindow.currentUserAccountId`,缺失时读取 `IcbuIM.UserUtil.currentUser.accountId`,再缺失返回 null,`apps/chrome-extension/src/onetalk/main-page/page-context.ts:24-44`。URL `activeAccountId` 只由 `readActiveAccountId()` 读取为选中对端上下文,`:11-22`,不得作为 `channelAccountId`。现有 observer 的 snapshot/update 都有登录账号 gate,`entry.ts:50-67,84-100`;测试覆盖 URL 不回退、logout 和切账号,`apps/chrome-extension/test/onetalk-contact-profile-observer.test.js:93-127`。
|
||||
|
||||
### 4. 当前唯一 selected `data-cid` 来源与 URL 形态
|
||||
|
||||
- 当前选中会话唯一来源是 DOM selector `.contact-item-container.selected[data-cid]`,由 `readSelectedConversationIds()` 读取并 trim/filter 非空 `data-cid`,`apps/chrome-extension/src/onetalk/main-page/page-context.ts:61-71`。
|
||||
- `readCurrentConversationId()` 只在恰好一个 selected 节点时返回 ID;`readConversationSelection()` 对无 selected/多 selected 分别返回 `none/multiple`,`page-context.ts:46-59`。不能取多个节点中的第一个,也不能用 URL `conversationId`、URL `activeAccountId`、DOM 文本、列表下标或参与者推断。
|
||||
- 相关测试明确改变 selected DOM 而不改变 URL:`apps/chrome-extension/test/onetalk-send-page.test.js:64-72`;点击后 bridge 重新发布新的 selected identity,`:203-228`;无/多 selected 不虚构 ID,`:231-243`。
|
||||
- 历史 doc 记录的 OneTalk 页面 URL 形态为 `https://onetalk.alibaba.com/message/weblitePWA.htm`,`docs/onetalk-customer-profile-fetch.md:3-7`;当前测试使用同一页面路径/origin,额外 query 仅是 fixture(`apps/chrome-extension/test/onetalk-contact-profile-observer.test.js:24-28`、`onetalk-send-page.test.js:34-44`)。仓库没有当前 live 页面 query 或 CRM script URL 的证据。
|
||||
- 当前 profile observer 用 row 的 `cid` 作为 profile 的 `conversationId`(`contact-observer/model.ts:68-73`),并不读取 selected `data-cid`。因此 CRM callback 关联当前会话时,最小安全接入应在 callback 处理时重新调用 `readConversationSelection()`,只接受唯一 selected `data-cid`,而不是复用 URL 或 observer 启动时缓存的选中值。
|
||||
|
||||
### 5. CRM script/JSONP 能力与被动 hook 边界
|
||||
|
||||
- 对 `apps/chrome-extension/src`、相关测试、`docs` 和父任务归档研究搜索 `queryCustomerInfo/queryCustomerTag/buyerInfo/JSONP/jsonp/MutationObserver/HTMLScriptElement`,没有找到现有 CRM script observer、JSONP callback hook、CRM request client 或对应测试。
|
||||
- 当前 `OneTalkPageWindow` 只建模 `location`、有限 DOM `querySelectorAll/addEventListener`、OneTalk globals 和 `EventBus`,`apps/chrome-extension/src/onetalk/main-page/model.ts:5-16`;它没有 `MutationObserver`、script 节点访问、callback 注册表或脚本 URL 读取能力。
|
||||
- 当前页面桥只负责同源、source、decoder、方向和 Port 原样转发;`apps/chrome-extension/src/onetalk/page-bridge/main.ts:45-57,72-95` 与 `page-bridge/isolated.ts:41-85` 没有 CRM 解释逻辑。CRM 观察必须留在 MAIN world,不应把 script/callback 原始值交给 ISOLATED、Service Worker 或 Bright。
|
||||
- 所以“只匹配 `queryCustomerInfo/queryCustomerTag`、hook 页面已有 callback、从 `buyerInfo` 白名单读取头像”的行为目前只有 active task PRD 的需求描述,没有仓库实现或页面 URL 证据。callback 参数形态、callback 命名/定位方式、script `src` 的真实协议和 JSONP 返回包裹层均未验证。
|
||||
|
||||
建议的被动安全边界(待实现者以 live page probe 固化):
|
||||
|
||||
1. MAIN 只观察已经存在或后来插入的 `<script>`,按 `src` 的固定特征匹配目标接口;不创建 script、不调用 fetch/XHR、不打开客户卡、不触发 CRM 请求、不创建 enrichment job。
|
||||
2. 只临时包装一个已确认属于匹配 script 的页面 callback;调用时只读取 `buyerInfo` 的头像白名单和当前重新校验的唯一 `data-cid`,立即构造 source-local record;不保存/打印原始 callback args、response、Cookie、token 或加密 ID。
|
||||
3. 包装函数必须保留并调用原 callback 语义,异常不能吞掉页面 callback;`pagehide` 时恢复 callback、断开 observer 并移除监听。没有可靠 callback 定位、唯一 `data-cid` 或登录账号时丢弃整个 CRM observation。
|
||||
4. callback 到达晚于会话切换时,必须以当时重新读取的登录账号和 selected `data-cid` 校验,不能使用安装时或上一次 hello 的缓存身份。
|
||||
|
||||
### 6. 现有跨层接入点与缺口
|
||||
|
||||
建议文件/函数(按责任边界):
|
||||
|
||||
| 文件 | 当前精确接入点 | 头像任务建议 |
|
||||
| --- | --- | --- |
|
||||
| `apps/chrome-extension/src/onetalk/main-page/contact-observer/model.ts` | `profileFromConversationRow():64-100`、`profilesFromConversationMap():102-110` | 负责 SDK/map 白名单、avatar candidate 固定优先级、URL-like 校验和指纹输入;不要返回 raw row。若 CRM source-local record 复杂,单独拆 `crm-observer/model.ts`,最终仍在一个 merge owner 合并。 |
|
||||
| `apps/chrome-extension/src/onetalk/main-page/contact-observer/entry.ts` | `consumeUpdates():50-75`、`snapshot():84-101` | 保持 snapshot/`syncData` 共用 map extractor;安装/清理 EventBus 的生命周期在 `:76-82`。CRM script observer 的安装、callback restore 和 pagehide cleanup 应有独立 owner。 |
|
||||
| `apps/chrome-extension/src/onetalk/main-page/page-context.ts` | `readChannelAccountId():34-44`、`readConversationSelection():51-59`、`readSelectedConversationIds():61-71` | CRM callback 关联必须复用真实登录账号和唯一 selected `data-cid`,不新建 URL 解析或 DOM 下标 helper。必要时仅扩充页面类型以表达可观察能力。 |
|
||||
| `apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts` | `installOneTalkPageFeatures():18-40` | 仅组合 observer;不要把 CRM 业务解析放入 message observer 或 page bridge。 |
|
||||
| `apps/chrome-extension/src/onetalk/page-bridge/model.ts` / `main.ts` | profile message type/decoder `model.ts:43-49,235-255`;sink `main.ts:130-144` | 头像加入共享 profile 后由已有 profile envelope 传输;保持 account、origin、direction 和 exact-key gate,不新增 raw CRM envelope。 |
|
||||
| `apps/onetalk-contract/src/model.ts` / `decoder.ts` | profile type `model.ts:244-258`;profile exact keys/guard `decoder.ts:131-190` | 单一拥有 `avatarUrl: string \| null` 的 wire contract 和严格 decoder;URL 清洗不得在每个消费者重复实现。 |
|
||||
| `apps/chrome-extension/src/onetalk/service-worker/contact-profile-coordinator.ts` / `storage.ts` | coordinator `:137-151,252-315`;ledger key/store `storage.ts:154-192,285-350,773-890` | 复用同一 profile ledger/fingerprint/pending/ACK fence;不要创建头像队列或写消息 stores。 |
|
||||
| `apps/server/src/mind-contact-profile.ts` / `websocket/handler.ts` | explicit Mind pick `mind-contact-profile.ts:25-79`;delivery/ACK `handler.ts:735-802` | 仅在 contract 完成后补 `avatarUrl` pick;保持 response-header-only、授权重校验和迟到 ACK fence。 |
|
||||
| `apps/mind-http-mock/src/server.ts` / tests | mock 只有 binding/session 两路,`server.ts:26-62`;当前 profile adapter tests `apps/server/test/mind-contact-profile.test.ts:26-177` | 若任务要求 mock/store 保留 URL,需先确认该 mock 是否是目标系统边界;当前仓库没有 customers store 或 Workbench UI,不能凭空补出生产 UI。 |
|
||||
|
||||
### 7. 不能跨边界的字段
|
||||
|
||||
头像跨层唯一允许的业务值应是清洗后的 `avatarUrl: string | null`。以下字段不得进入 page bridge、Port、Service Worker profile envelope/ledger、Bright frame、Mind HTTP body、诊断或测试输出:
|
||||
|
||||
- `chatToken`、`aliIdEncrypt`、`accountIdEncrypt`、`loginIdEncrypt`、`kHTAccessToken`;
|
||||
- Cookie、CSRF、`secReqToken`、`ctoken`、`_tb_token_` 及其它认证/加密参数;
|
||||
- 原始 conversation row、原始 CRM JSONP response、callback 原始参数、图片二进制、DOM `<img>` 节点或页面外部资源响应;
|
||||
- `mindUserId`、`workspaceId`、`deviceId` 不得变成 profile 业务字段;`deviceId` 只能留在既有 plugin WS scope/连接完整性上下文。
|
||||
|
||||
现有 repo 已对基础 profile raw-row/token 隔离有证据:observer test 的敏感 row fixture 与断言在 `apps/chrome-extension/test/onetalk-contact-profile-observer.test.js:7-19,46-63`;Mind adapter 显式 pick 和敏感值负向断言在 `apps/server/test/mind-contact-profile.test.ts:55-102`。这些测试当前不包含 avatar URL 或 CRM callback。
|
||||
|
||||
### 8. 结构性不变量、异步/副作用边界与可行性
|
||||
|
||||
不变量 owner:
|
||||
|
||||
1. `contact-observer/model.ts`(或拆分后的 page-observation merge module)拥有 row/map 与 CRM source-local record → 一个严格 `OneTalkContactProfile` 的白名单、头像优先级、URL/null 语义和 fingerprint。
|
||||
2. `page-context.ts` 拥有真实登录账号与 selected `data-cid` 语义;不得让 CRM hook 自己解析 URL 或复刻 selector。
|
||||
3. `page-bridge/model.ts`/`runtime.ts`/`page-runtime-host.ts` 拥有 origin、direction、Port identity、account/epoch fail-closed;当前 profile envelope 校验在 `runtime.ts:311-342`,host 的 coordinator scope gate 在 `page-runtime-host.ts:164-174`。
|
||||
4. profile coordinator/storage 拥有 durable-first、latest fingerprint、pending、ACK 和重连恢复;消息 `SyncEngine` 不应吸收 avatar state。
|
||||
5. `onetalk-contract` 与 `mind-contact-profile.ts` 分别拥有跨边界字段集合;Mind/Workbench 业务落库和展示不在当前仓库。
|
||||
|
||||
关键 await、mutation 和不可逆边界:
|
||||
|
||||
- MAIN EventBus `on()` 注册及 unsubscribe/pagehide 清理:`contact-observer/entry.ts:76-82`;未来 CRM callback wrapper、MutationObserver 和 script listeners 都必须在同一 pagehide rollback 边界恢复/断开。
|
||||
- MAIN sink 的 `window.postMessage` 是首次跨上下文副作用:`page-bridge/main.ts:130-143`;ISOLATED 收到后仅校验并 `Port.postMessage`:`page-bridge/isolated.ts:54-75`。
|
||||
- Service Worker 对 profile observation 先 await `persistPageProfileObservation`,完成后才 notify:`runtime.ts:327-342`;host 再 await coordinator `observe()`,`page-runtime-host.ts:164-174`。头像不能在 durable commit 前进入 Bright send。
|
||||
- coordinator 的 Bright send/ACK map 和 ledger transactions 位于 `contact-profile-coordinator.ts:137-153,252-315` 与 `storage.ts:835-890`;迟到 ACK 只能确认相同 fingerprint。
|
||||
- 服务端 Mind delivery 是不可撤销的外部副作用:`handler.ts:750-770` await delivery 后重新校验 policy/authorization/canonical connection,再在 `:771-802` 发 ACK。远端已收到但本地 fence 失败时不能伪称 exactly-once,也不能用 ACK 回滚 Mind。
|
||||
|
||||
仓库内可证明:当前 snapshot/`syncData` 共用入口、真实登录账号 fallback、唯一 selected `data-cid` 读取、页面桥/ledger/adapter 的安全与 durable 边界、基础单聊/群聊分类及现有测试基线。仓库外必须验证:当前生产 OneTalk bundle 是否在 conversation map 提供四个头像候选、真实优先级/URL 协议、CRM script URL 与 callback 形态、客户卡自然加载时序、真实 Mind collector `/collector/v1/sync-batches` 与 `customers[].avatarUrl`、Workbench `tm-binding-avatar` 语义及生产部署后的 extension build。
|
||||
|
||||
### 9. 可执行验收 probes
|
||||
|
||||
建议实现/检查双方运行同一矩阵:
|
||||
|
||||
1. **SDK/map 白名单**:fixture 分别只在 `contact.fullPortrait`、row `fullPortrait`、row `avatarUrl`、row `portraitUrl` 放有效 URL,断言固定优先级;同一字段放空白、`"a"`、`javascript:`、`data:`、`blob:`、协议相对值和合法 `https`/`http` 值,固定 validator 结果。加入敏感 row,断言只返回 profile keys。
|
||||
2. **共用入口/fingerprint**:snapshot 与 `syncData` 输入相同 map,断言输出相同 avatar/ fingerprint;头像变化触发一次,未变化不重复;已确认无有效头像输出 `avatarUrl: null`,未确认 CRM 不覆盖 SDK 值。
|
||||
3. **单/群与身份**:加入已验证单聊 map、每一种真实群聊标记 fixture、缺 `cid/aliId` fixture;群聊不 emit,缺身份/登录账号不 emit;登录账号由 `currentUserAccountId` 或 UserUtil 提供,URL `activeAccountId` 变化不改变归属。
|
||||
4. **selected cid**:零、一个、多个 `.contact-item-container.selected[data-cid]`;callback 处理时 selected 从 A 改 B、URL 不变、URL `conversationId` 与 DOM 冲突;只接受唯一 DOM cid,其他均 fail closed。
|
||||
5. **被动 CRM JSONP**:自然插入匹配/不匹配 script,已有 callback 被调用一次;断言无 `fetch/XHR/appendChild`、无客户卡打开、原 callback 保留;`buyerInfo` 每个头像白名单字段逐一覆盖,原始 callback args/token/row 不进入 sink;script 已存在、重复 script、callback 异步晚到和 `pagehide` 后回调均覆盖。
|
||||
6. **账号/epoch race**:hello A 后 logout、切 B 或 selected cid 变化,再释放旧 callback;断言无 A ledger 写入、无跨账号 profile frame;旧 Port/config callback 到达新 session 时 no-op。
|
||||
7. **跨层回归**:contract exact-key/敏感字段/`avatarUrl:null`,page bridge account/origin/direction,ledger latest-wins/ACK/reconnect,Mind body pick 和 mock retain;既有消息 observer、历史、发送三态和授权测试必须同时通过。
|
||||
|
||||
当前基线(本研究只读执行):
|
||||
|
||||
- `node --experimental-strip-types --test apps/chrome-extension/test/onetalk-contact-profile-observer.test.js apps/chrome-extension/test/onetalk-page-bridge.test.js apps/chrome-extension/test/onetalk-send-page.test.js apps/chrome-extension/test/onetalk-current-conversation-history.test.js`:41 tests passed。
|
||||
- `node --experimental-strip-types --test apps/onetalk-contract/test/contract.test.ts`:20 tests passed。
|
||||
- `node --experimental-strip-types --test apps/server/test/mind-contact-profile.test.ts apps/server/test/onetalk-profile-websocket.test.ts`:16 tests passed。
|
||||
|
||||
这些通过只证明当前无头像的基础 profile/bridge/SDK history/server adapter 行为;不能作为头像、CRM 或真实 collector 的通过证据。
|
||||
|
||||
## Caveats / Not Found
|
||||
|
||||
- 未在当前环境打开真实 Chromium、OneTalk 页面或 CRM 客户卡,也未读取网络请求/静态生产 bundle;因此 avatar map 字段、优先级、URL 协议、CRM JSONP callback 形态均为 `external_unverified`。历史 doc 的 2026-08-27 页面探查只能作为 dated observation,不能代替当前联调。
|
||||
- 没有找到 `queryCustomerInfo`、`queryCustomerTag`、`buyerInfo`、JSONP callback observer、`MutationObserver` 或 CRM 相关测试。实现前需要 live page probe 先确认脚本 URL、callback 名称/定位、callback 参数层级和清理策略。
|
||||
- 当前仓库没有 Workbench 前端、`/collector/v1/sync-batches` endpoint、`customers[].avatarUrl` store 或 `tm-binding-avatar` 实现;Mind mock 只提供 binding/session 授权路径(`apps/mind-http-mock/src/server.ts:26-62`)。这些属于外部/未找到边界,不应在 P2 页面观察包中臆造。
|
||||
- 当前 profile contract、page profile envelope、ledger、Bright/Mind adapter 均无 `avatarUrl`;建议先由 P1 contract 明确 `string | null`、URL validator 与 exact-key 语义,再由 P2 页面 observer 接入,避免页面先产生无法被 decoder 接收的字段。
|
||||
- 本研究未修改应用代码、测试、规范或 Git;仅写入本文件。工作树原有 `.trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/task.json` 修改及当前任务目录状态未被清理。
|
||||
|
||||
## Related Specs
|
||||
|
||||
- `.trellis/spec/chrome-extension/frontend/onetalk/contact-profile-sync.md:49-89`:profile source/identity、wire、ledger、敏感字段和 fail-closed 矩阵;当前版本尚未包含 `avatarUrl`。
|
||||
- `.trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md:58-135`:MAIN/ISOLATED/SW ownership、页面 identity、唯一 selected cid 和 profile envelope。
|
||||
- `.trellis/spec/chrome-extension/frontend/onetalk/runtime-sync.md:25-69`:跨层数据流、账号/epoch、durable-first 和 profile 与消息隔离。
|
||||
- `.trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/onetalk-customer-profile-cross-layer.md`、`recheck-contract-page.md`:父任务跨层边界、当前 profile 骨架、历史浏览器证据和 external-unverified 限制。
|
||||
- `docs/onetalk-customer-profile-fetch.md:87-111,151-223,268-292,605-700`:历史 OneTalk map/EventBus/详情探查;详情刷新和敏感参数明确不属于当前第一阶段。
|
||||
+110
@@ -0,0 +1,110 @@
|
||||
# Research: server Mind/Workbench avatar boundaries
|
||||
|
||||
- Query: 追踪 Bright WebSocket profile handler、Mind HTTP adapter、授权与 response-body 安全边界、mind-http-mock 现状,并确认 TradeMind Workbench `customers[].avatarUrl` 与 `/collector/v1/sync-batches` 是否存在;给出可实现范围、缺口、建议文件/行号及测试/构建验证。
|
||||
- Scope: mixed(仓库源码、现有 docs/spec、父任务归档研究;真实 Mind/Workbench 属 external boundary)
|
||||
- Date: 2026-09-01
|
||||
|
||||
## Findings
|
||||
|
||||
### 1. Current Bright profile WebSocket path
|
||||
|
||||
- `apps/server/src/websocket/handler.ts:580-586` maps `contact.profile.observed` to the `sync` authorization operation. `apps/server/src/websocket/handler.ts:575-578` rejects profile frames unless they are on a plugin connection. Before the profile branch, the handler checks authenticated connection type/scope (`:566-573`), reauthorizes the current frame (`:638-661`), then checks binding, complete Mind scope, authorization version and requested permission (`:672-720`). A valid profile therefore needs the canonical plugin route, matching `channelAccountId + deviceId`, the current binding, `sync` authorization and `read` permission.
|
||||
- `apps/server/src/websocket/handler.ts:727-733` obtains the registry canonical connection and creates a commit guard; it does not derive delivery context from a client-provided Mind user/workspace. `sourceContextForConnection()` at `:229-240` derives binding, Mind scope, account and device from the registered plugin connection.
|
||||
- The profile branch is `apps/server/src/websocket/handler.ts:735-802`: it sends an optional development upload observation, fails closed without `contactProfileDelivery`, awaits delivery, reauthorizes after the await (`:771`), checks policy epoch (`:772-775`), commit guard (`:776-781`) and canonical connection (`:782-783`), then emits `contact.profile.ack` only when all checks pass (`:784-801`). A delivery exception is converted to `no_response` and does not ACK (`:752-770`).
|
||||
- `apps/server/src/websocket/index.ts:49-123` enforces route-specific Origin before the handler and forwards only request Cookie/Origin into handler context. `/ws/plugin` and `/ws/mind` are separate routes; profile upload is therefore not a Mind-page HTTP route and does not use the Mind page session Cookie.
|
||||
- The current handler is already structurally suitable for an avatar field because it passes decoded `frame.payload.profiles` to the existing delivery port. No avatar-specific handler branch or second queue is present or needed in the current design. The shared contract must accept the new field first; otherwise the handler rejects the frame before authorization/delivery.
|
||||
|
||||
### 2. Mind HTTP profile adapter
|
||||
|
||||
- `apps/server/src/mind-contact-profile.ts:5` defines only the locally named fixed path `/internal/bright/onetalk/contact-profiles`. `:7-11` defines the delivery input as `channelAccountId + binding + profiles`; there is no separate collector path or Workbench contract.
|
||||
- The runtime HTTP boundary is explicit today: `MindContactProfile` at `apps/server/src/mind-contact-profile.ts:25-39` is a `Pick` of the current 12 profile fields, `profileForMind()` at `:59-72` constructs those fields, and `bodyFor()` at `:74-79` sends only top-level `channelAccountId`, `binding`, and mapped `profiles`. The avatar implementation point is the same `Pick` and constructor: add `avatarUrl` only after the shared contract defines its exact `string | null` semantics. Do not replace the pick with a spread.
|
||||
- `createMindContactProfileDelivery()` at `apps/server/src/mind-contact-profile.ts:81-101` builds the endpoint from the configured fixed `baseUrl`, sends JSON with `AbortSignal.timeout(timeoutMs)` and `redirect: "error"`, and reads only `response.status`. Any response status is classified as `1xx`/`2xx`/`3xx`/`4xx`/`5xx` (`:51-57`) and returned as delivered; fetch rejection/timeout returns `{ delivered: false, reason: "no_response" }` (`:87-99`). It never calls `response.text()`, `response.json()`, accesses `response.body`, caches a response, or forwards a response body.
|
||||
- This adapter does not authorize the plugin. Authorization belongs to the WebSocket handler and injected `OneTalkAuthorizationReader`; the adapter receives already admitted `channelAccountId`, `binding`, and decoded profiles. `apps/server/src/app.ts:30-40,79-97` wires the same optional delivery dependency into the WebSocket installation, and creates the adapter from the existing Mind authorization configuration when present (`:79-83`). There is no profile-specific URL/configuration, timeout, auth header, or persistence dependency.
|
||||
- The existing server contract explicitly says response headers are the only delivery signal and no response body is read (`.trellis/spec/server/backend/mind-contact-profile.md:26-36,40-49`). This is an important distinction from authorization: authorization response bodies must be read and strictly decoded, while profile response bodies must remain unread.
|
||||
|
||||
### 3. Authorization and security boundaries
|
||||
|
||||
- Binding/session response decoding is owned by `apps/onetalk-contract/src/authorization.ts:39-79`: successful Mind authorization responses must contain exactly `authorizationVersion`, `binding`, `mindScope`, and `permissions`; rejection responses must be a single stable `code`. Unknown fields, missing fields, duplicate permissions and malformed scopes fail closed.
|
||||
- `apps/server/src/mind-authorization.ts:49-90` reads `response.json()` for authorization only, checks the expected HTTP status/code pair, strictly decodes the body and verifies returned `mindScope.channelAccountId` and (for binding auth) binding against the request (`:83-89`). Raw Mind JSON is not returned to the page or profile delivery path.
|
||||
- `apps/server/src/mind-authorization.ts:92-129,156-205` sends plugin binding authorization with only `channelAccountId + binding` (`:158-175`). For a Mind page it sends only `channelAccountId` plus the original Cookie in the `cookie` header (`:180-204`). Cookie is never part of the binding/profile body. `apps/server/src/websocket/registry.ts:45-75` holds the Mind page Cookie only in a non-serializable connection capability and clears it on close.
|
||||
- The profile handler uses plugin authorization with the registered binding and plugin scope; it does not use the client-declared Mind scope as authority. The complete scope must match the authorization result in `handler.ts:679-690`, and the post-delivery reauthorization repeats binding/version/scope/read checks in `:253-284`.
|
||||
- Safe diagnostics are separately typed. `apps/server/src/websocket/diagnostics.ts:3-25` limits profile delivery diagnostics to event/request/frame metadata, stable result/status class, profile count and duration. `handler.ts:99-111,761-769` does not put profile values or HTTP body into that event. `apps/server/src/mind-authorization.ts:22-31,131-146` reports only endpoint class, operation, outcome, status and stable code; its diagnostic sink intentionally excludes Cookie, request body, response body and exceptions.
|
||||
- The development-only upload sink is distinct from safe diagnostics: `apps/server/src/dev-entry.ts:43-61` registers `onContactProfileUpload` and prints the already decoded whitelist profile, while production `startServer()` calls `createApp()` without that sink (`apps/server/src/entry.ts:14-25,42-47`). It must remain development-only and must not become production logging. Adding `avatarUrl` means this sink will receive the approved URL/null field, not raw rows; no raw CRM/OneTalk object should be introduced at this boundary.
|
||||
- Bright has no profile persistence boundary today. The profile handler never calls `OneTalkService`/repository. Current schema `apps/server/src/database/schema/onetalk.ts:45-203` contains message, conversation and message-anomaly tables only; no profile table/column/migration. The parent archived server research also verified no profile path in `apps/server/src/database`, `apps/server/src/onetalk` or `apps/server/drizzle` (see `.trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/check-server-delivery.md`, findings SRV-005/F-012/F-013 and verification notes).
|
||||
|
||||
### 4. `mind-http-mock` current state
|
||||
|
||||
- `apps/mind-http-mock/src/server.ts:26-56` is a stateless router exposing exactly two POST endpoints: binding authorization and session authorization. Unknown methods/routes return plain 404; there is no `/internal/bright/onetalk/contact-profiles`, `/collector/v1/sync-batches`, customer store, or response fixture for profile delivery.
|
||||
- Binding request parsing is exact and accepts only `channelAccountId + binding` (`apps/mind-http-mock/src/authorization/binding.ts:11-23`). It validates content type/body and then returns scope mismatch or binding revoked, or the four-field authorization success body (`:35-68`).
|
||||
- Session request parsing is exact and accepts only `channelAccountId` (`apps/mind-http-mock/src/authorization/session.ts:11-19`). It compares the incoming Cookie exactly to the configured fixture (`:59-68`) and returns the same authorization shape; it does not infer identity from arbitrary headers.
|
||||
- `apps/mind-http-mock/src/request.ts:5-34` caps request bodies at 64 KiB and JSON parses the complete body. `apps/mind-http-mock/src/response.ts:19-50` emits JSON only for authorization/rejection responses; no profile storage or response-body preservation exists.
|
||||
- The mock’s tests confirm the current behavior, not a profile contract: `apps/mind-http-mock/test/server.test.ts:74-83` checks the two paths/defaults; `:85-156` checks binding/session allow/reject behavior over TCP; `:158-192` checks unknown routes and exact body shapes; `:229-250` switches the two auth outcomes independently. They are not evidence that a profile or collector endpoint exists.
|
||||
|
||||
### 5. TradeMind Workbench / collector search result
|
||||
|
||||
- Repository-wide search outside task artifacts and archived task research found no `avatarUrl` occurrence, no `customers[].avatarUrl` model/rendering code, no `/collector/v1/sync-batches`, and no `sync-batches` implementation.
|
||||
- There is only generic architectural mention of TradeMind in `prd.md`, `prd-detail.md`, `prd-server.md` and related specs, plus OneTalk test URLs containing `/workbench` (`apps/chrome-extension/test/onetalk-sync-runtime.test.js:155`, `service-worker-entry-config.test.js:162`, `onetalk-error-diagnostics.test.js:258`, `onetalk-service-worker-runtime.test.js:16`). These are page/routing fixtures, not a Workbench customer model.
|
||||
- The only current docs file is `docs/onetalk-customer-profile-fetch.md`; its existing profile exploration is not a Workbench/collector implementation. The parent research specifically warns that exploratory detail-refresh/legacy CRM examples are not the current production contract (`.trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/onetalk-customer-profile-cross-layer.md:193-199`).
|
||||
- Consequently, this repository can extend the shared profile and Bright delivery boundary, and can add a local test fixture only if an implementer deliberately defines a mock contract. It cannot prove or safely implement the real TradeMind customer upsert/rendering contract. Do not invent a `/collector/v1/sync-batches` route or `customers[]` business model from the task wording alone.
|
||||
|
||||
### 6. Feasibility and implementation locus
|
||||
|
||||
- Locally feasible: extend `OneTalkContactProfile` and exact decoder shape in `apps/onetalk-contract/src/model.ts:244-258` and `apps/onetalk-contract/src/decoder.ts:131-192`; extend the server adapter’s explicit pick in `apps/server/src/mind-contact-profile.ts:25-79`; update profile fixtures and security/status/fence tests; optionally extend the existing mock only when a concrete local profile receipt contract is approved.
|
||||
- The existing WebSocket handler/registry authorization and ACK fence can be reused. The avatar field should flow through the already decoded profile frame and existing `contactProfileDelivery` port. There is no local evidence requiring a new WebSocket route, Bright DB table, message projection, outbox, or second queue.
|
||||
- URL validation is not currently a server concern: the current server adapter forwards decoded profile values without URL interpretation. The single validator should be owned at the source/shared contract boundary after real OneTalk URL evidence is available; do not add a second server validator that silently changes accepted values. The server boundary must preserve valid URL strings and explicit `null` and reject/leak no raw object values.
|
||||
- Real Mind endpoint existence, authentication mechanism for `/internal/bright/onetalk/contact-profiles`, maximum body size, idempotency/upsert behavior, response status semantics, manual-field precedence, and the exact Workbench `customers[]` schema are not represented in this repository. These are `external_unverified` and require a product/architecture decision or real Mind/Workbench contract before calling the integration complete.
|
||||
|
||||
## Invariants and Acceptance Probes
|
||||
|
||||
### Invariant owner and sources of truth
|
||||
|
||||
1. Shared profile wire shape/direction/exact keys: `apps/onetalk-contract/src/model.ts` + `decoder.ts`. The decoder is the source of truth for `avatarUrl: string | null`, required-vs-optional semantics and sensitive-key rejection.
|
||||
2. Plugin admission and authorization: `apps/server/src/websocket/handler.ts` using the injected authorization reader result and registry canonical connection. Client scope is input to compare, never authority.
|
||||
3. Connection/policy/late-ACK fence: `apps/server/src/websocket/registry.ts` for generation/connection guard plus handler reauthorization after external delivery. A profile ACK means response headers arrived and the local fence still holds; it does not mean Mind customer DB commit.
|
||||
4. HTTP body and response-body boundary: `apps/server/src/mind-contact-profile.ts`. It owns the explicit field pick, fixed endpoint construction, status-class/no-response result and no-body-read rule.
|
||||
5. Mind customer persistence/upsert and Workbench rendering: external Mind/Workbench system. Bright must not become a second source of truth.
|
||||
|
||||
### Async, mutation and side-effect map
|
||||
|
||||
- Synchronous decode (`decoder.ts:173-192,376-397`) must fail before authorization or external delivery for malformed avatar type, unknown fields, wrong direction, empty batch or oversized frame.
|
||||
- Authorization awaits occur in `handler.ts:638-670`; binding/scope/version/permission checks follow at `:672-720`. The handler creates the local commit guard at `:733` before the external side effect.
|
||||
- The irreversible external side effect is `contactProfileDelivery` at `handler.ts:750-770`, which awaits response headers. During this await, policy pause, socket close/replacement, generation change, remote binding revoke, authorization-version change or read removal must prevent a late ACK.
|
||||
- Post-delivery reauthorization is at `handler.ts:771`, followed by policy/guard/canonical checks at `:772-796`; ACK send at `:797-801` is the final local side effect. There is no Bright DB mutation or rollback operation for profile delivery.
|
||||
- If Mind has already received the HTTP request but Bright receives no response, local state can only be `no_response`/pending; the repository cannot roll back an external request. If response headers arrived but authorization changed before ACK, suppress ACK and treat the local delivery result as not confirmed. This is an unavoidable external `delivery_unknown` boundary, not evidence of Mind DB rollback.
|
||||
|
||||
### Deterministic test/mutation matrix
|
||||
|
||||
- Contract: valid plugin profile with URL and explicit `null` accepted; missing/wrong-typed avatar rejected; unknown profile key and injected `chatToken`, Cookie, encrypted ID, raw row or binary rejected; profile observed on Mind connection and profile ACK on Mind connection rejected; empty/oversized batches rejected.
|
||||
- Adapter: outgoing body contains exactly `channelAccountId`, `binding`, and the approved profile fields including `avatarUrl`; URL and `null` survive unchanged; injected runtime secrets/extra keys do not cross; status `100/200/204/302/400/404/500/503` is status-class delivered; network throw/timeout is `no_response`; response `body` getter, `text()`, `json()` and stream readers are never touched.
|
||||
- Authorization: missing/invalid binding, wrong account/device scope, no `read`, revoked binding, changed authorization version, and unavailable Mind authorization result in no delivery/ACK. Confirm plugin auth request contains account/device/binding only and no Cookie/Mind user/workspace.
|
||||
- Async fence: hold `contactProfileDelivery` on a Promise; independently pause policy, terminate/replace canonical plugin, revoke binding, change authorization version, remove `read`, then resolve delivered; assert no late ACK and stable close/error behavior. Repeat with delivery resolved before the fence and document that external receipt cannot be rolled back.
|
||||
- Isolation: spy `OneTalkService`, repository and database calls; profile handling must invoke none. Search schema/migrations/message/anomaly/checkpoint paths for profile writes. Existing message/sync/send WebSocket tests must remain green.
|
||||
- Mock: if a local profile mock is approved, test exact request body, URL/null retention/clear semantics, no raw fields and no message-path coupling. Current mock tests cannot be extended safely without deciding whether the target is the locally named `/internal/bright/onetalk/contact-profiles` or the externally requested `/collector/v1/sync-batches`; do not silently conflate them.
|
||||
|
||||
### Validation evidence and next checks
|
||||
|
||||
- Passed in this research turn: `node --experimental-strip-types --test apps/server/test/mind-contact-profile.test.ts apps/server/test/onetalk-profile-websocket.test.ts apps/server/test/mind-authorization.test.ts apps/onetalk-contract/test/contract.test.ts` (42/42); `node --experimental-strip-types --test apps/server/test/*.test.ts` (86 passed, 1 skipped because `TEST_DATABASE_URL` is unset); `node --experimental-strip-types --test apps/mind-http-mock/test/server.test.ts` with loopback capability (7/7).
|
||||
- Passed in this research turn: direct no-emit TypeScript checks for `apps/onetalk-contract`, `apps/server` and `apps/mind-http-mock`; scoped `oxfmt --check` over the relevant source/tests.
|
||||
- The first combined run of the mock tests failed only because the restricted sandbox returned `listen EPERM: operation not permitted 127.0.0.1`; the same test passed 7/7 after the required loopback capability was granted. This is an environment boundary, not a mock assertion failure.
|
||||
- Not run here: `pnpm build`/package build, because this research package is permitted to write only the research report and builds may emit `dist`/generated artifacts. The implementer should run the project’s prescribed `pnpm format:check`, `pnpm typecheck`, `pnpm test`, `pnpm build`, direct package tests and `git diff --check` after implementation. Existing parent archive reports previously recorded server/contract build and full test success, but that is historical evidence, not real Mind/Workbench integration evidence.
|
||||
- External/unverified: real OneTalk/CRM page data and passive JSONP behavior, production Mind HTTP endpoint/TLS/Cookie policy, `/collector/v1/sync-batches`, TradeMind Workbench customer upsert/rendering, and PostgreSQL integration without `TEST_DATABASE_URL`.
|
||||
|
||||
## Caveats / Not Found
|
||||
|
||||
- No real Mind, TradeMind or Workbench source/API contract is present in this repository. The path `MIND_CONTACT_PROFILE_PATH` is a Bright-local constant only; its production existence and semantics must not be inferred from that constant.
|
||||
- No `avatarUrl`, `customers[].avatarUrl`, `/collector/v1/sync-batches`, or `sync-batches` implementation was found outside the current task artifacts and archived research. Existing `/workbench` strings are OneTalk URL fixtures, not Workbench customer code.
|
||||
- The current server adapter’s 12-field pick and current profile contract are pre-avatar. Any implementation that updates only page/extension code will fail at the shared decoder or omit the field at the Mind boundary.
|
||||
- The current `mind-http-mock` is authorization-only and stateless. It does not retain profile URLs or clear `null` values; such behavior requires an explicitly defined local mock/store contract and cannot stand in for real Mind persistence.
|
||||
- Authorization response bodies are intentionally read and strictly decoded; profile delivery response bodies must remain unread. Tests and reviews should not generalize the no-body-read rule to the authorization adapter.
|
||||
- No application code, test code, spec, docs, task manifest or Git index was modified by this research. The only new file is this report under the active task’s `research/` directory.
|
||||
|
||||
## Related specs and internal references
|
||||
|
||||
- `.trellis/spec/server/backend/mind-contact-profile.md:5-63` — existing profile delivery contract, explicit pick, response/no-response and async ACK fence.
|
||||
- `.trellis/spec/server/backend/mind-authorization.md:1-107` — binding/session endpoints, Cookie/Origin rules, profile delivery boundary and validation matrix.
|
||||
- `.trellis/spec/server/backend/logging-guidelines.md:27-49` — safe WebSocket/profile/Mind diagnostics.
|
||||
- `.trellis/spec/server/backend/error-handling.md:35-79,108-149` — stable WS/HTTP errors and sensitive response boundary.
|
||||
- `.trellis/spec/server/backend/directory-structure.md:1-75` — server and mock package ownership.
|
||||
- `.trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/check-server-delivery.md` and `check-server-delivery-revalidation.md` — parent server delivery review and resolved late-ACK/whitelist findings.
|
||||
- `.trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch/research/recheck-server-validation.md` — parent cross-layer server invariant, async fence and external-boundary analysis.
|
||||
- No external web documentation was used; all external Mind/Workbench claims remain `external_unverified`.
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"id": "onetalk-customer-avatar-profile",
|
||||
"name": "onetalk-customer-avatar-profile",
|
||||
"title": "OneTalk 客户头像 URL 采集与同步",
|
||||
"description": "在已完成的 OneTalk 基础客户资料同步之上,被动采集客户/买家头像 URL,合并会话 SDK 与 CRM 客户卡来源,并沿现有 profile ledger、Bright WebSocket 和 TradeMind customers[].avatarUrl 链路上传;不下载图片、不扫描 DOM img、不采集卖家头像。",
|
||||
"status": "in_progress",
|
||||
"dev_type": null,
|
||||
"scope": "cross-package",
|
||||
"package": null,
|
||||
"priority": "P2",
|
||||
"creator": "ybf",
|
||||
"assignee": "ybf",
|
||||
"createdAt": "2026-09-01",
|
||||
"completedAt": null,
|
||||
"branch": null,
|
||||
"base_branch": "main",
|
||||
"worktree_path": null,
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"parent": "08-31-onetalk-customer-profile-fetch",
|
||||
"relatedFiles": [
|
||||
"apps/chrome-extension/src/onetalk/main-page",
|
||||
"apps/chrome-extension/src/onetalk/page-bridge",
|
||||
"apps/chrome-extension/src/onetalk/service-worker",
|
||||
"apps/onetalk-contract/src/model.ts",
|
||||
"apps/onetalk-contract/src/decoder.ts",
|
||||
"apps/server/src/mind-contact-profile.ts",
|
||||
"apps/server/src/websocket/handler.ts",
|
||||
"apps/mind-http-mock/src",
|
||||
"docs/onetalk-customer-profile-fetch.md",
|
||||
"prd.md"
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {
|
||||
"parent_path": ".trellis/tasks/archive/2026-08/08-31-onetalk-customer-profile-fetch",
|
||||
"source_plan": "user-provided-avatar-collection-conclusion"
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,9 @@
|
||||
"commit": null,
|
||||
"pr_url": null,
|
||||
"subtasks": [],
|
||||
"children": [],
|
||||
"children": [
|
||||
"09-01-onetalk-customer-avatar-profile"
|
||||
],
|
||||
"parent": null,
|
||||
"relatedFiles": [
|
||||
"docs/onetalk-customer-profile-fetch.md",
|
||||
@@ -33,4 +35,4 @@
|
||||
],
|
||||
"notes": "",
|
||||
"meta": {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// 被动观察页面自然产生的 OneTalk CRM JSONP callback
|
||||
|
||||
import type {
|
||||
OneTalkMutationObserver,
|
||||
OneTalkMutationRecord,
|
||||
OneTalkPageWindow,
|
||||
} from "../model.ts";
|
||||
import { isRecord } from "../../../lib/guards.ts";
|
||||
import { crmAvatarObservationFromBuyerInfo, type OneTalkCrmAvatarObservation } from "./model.ts";
|
||||
|
||||
const CRM_SCRIPT_NAMES = ["queryCustomerInfo", "queryCustomerTag"] as const;
|
||||
const CALLBACK_PARAM_NAMES = ["callback", "jsonpCallback", "cb", "jsonp"] as const;
|
||||
|
||||
type CallbackArgumentsListener = (args: readonly unknown[]) => void;
|
||||
|
||||
export type OneTalkCrmCallbackHook = {
|
||||
name: string;
|
||||
install: (listener: CallbackArgumentsListener) => () => void;
|
||||
};
|
||||
|
||||
export type OneTalkCrmObservationSink = (observation: OneTalkCrmAvatarObservation) => void;
|
||||
|
||||
export type OneTalkCrmObserverOptions = {
|
||||
observeMatchingCallbacks?: (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
onCallback: (hook: OneTalkCrmCallbackHook) => void,
|
||||
onDiagnostic?: (code: string) => void,
|
||||
) => () => void;
|
||||
};
|
||||
|
||||
type ScriptLike = {
|
||||
tagName?: unknown;
|
||||
src?: unknown;
|
||||
getAttribute?: (name: string) => unknown;
|
||||
querySelectorAll?: (selector: string) => ArrayLike<unknown>;
|
||||
};
|
||||
|
||||
type CallbackOwner = Record<string, unknown>;
|
||||
|
||||
const isScript = (value: unknown): value is ScriptLike => {
|
||||
if (!isRecord(value)) return false;
|
||||
if (typeof value.tagName === "string") return value.tagName.toLowerCase() === "script";
|
||||
return typeof value.src === "string" || typeof value.getAttribute === "function";
|
||||
};
|
||||
|
||||
const scriptSource = (script: ScriptLike): string | null => {
|
||||
try {
|
||||
const attribute = script.getAttribute?.("src");
|
||||
if (typeof attribute === "string" && attribute.length > 0) return attribute;
|
||||
return typeof script.src === "string" && script.src.length > 0 ? script.src : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isCrmScriptSource = (source: string, href: string): boolean => {
|
||||
try {
|
||||
const pathname = new URL(source, href).pathname.replace(/\/+$/u, "");
|
||||
return CRM_SCRIPT_NAMES.some((name) => new RegExp(`(?:^|/)${name}$`, "u").test(pathname));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const callbackNameFromScript = (source: string, href: string): string | null => {
|
||||
try {
|
||||
const url = new URL(source, href);
|
||||
for (const parameter of CALLBACK_PARAM_NAMES) {
|
||||
const value = url.searchParams.get(parameter)?.trim();
|
||||
if (!value) continue;
|
||||
const normalized = value.replace(/^window\./u, "");
|
||||
if (normalized.split(".").every((part) => /^[A-Za-z_$][A-Za-z0-9_$]*$/u.test(part))) {
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const callbackOwnerFor = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
name: string,
|
||||
): { owner: CallbackOwner; key: string } | null => {
|
||||
const parts = name.split(".");
|
||||
if (parts.length === 0) return null;
|
||||
let owner: CallbackOwner = pageWindow as unknown as CallbackOwner;
|
||||
for (const part of parts.slice(0, -1)) {
|
||||
const next = owner[part];
|
||||
if (!isRecord(next)) return null;
|
||||
owner = next;
|
||||
}
|
||||
return { owner, key: parts.at(-1)! };
|
||||
};
|
||||
|
||||
const callbackHookFor = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
name: string,
|
||||
): OneTalkCrmCallbackHook | null => {
|
||||
const target = callbackOwnerFor(pageWindow, name);
|
||||
if (!target) return null;
|
||||
const original = target.owner[target.key];
|
||||
if (typeof original !== "function") return null;
|
||||
const descriptor = Object.getOwnPropertyDescriptor(target.owner, target.key);
|
||||
|
||||
return {
|
||||
name,
|
||||
install: (listener) => {
|
||||
let wrapper: ((...args: unknown[]) => unknown) | undefined;
|
||||
try {
|
||||
wrapper = function crmCallbackWrapper(this: unknown, ...args: unknown[]) {
|
||||
let result: unknown;
|
||||
try {
|
||||
result = (original as (...values: unknown[]) => unknown).apply(this, args);
|
||||
} finally {
|
||||
try {
|
||||
listener(args);
|
||||
} catch {
|
||||
// Observation must not alter the page callback's behavior.
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
if (descriptor && !descriptor.configurable && descriptor.writable !== true) {
|
||||
return () => undefined;
|
||||
}
|
||||
if (descriptor && descriptor.configurable) {
|
||||
Object.defineProperty(target.owner, target.key, {
|
||||
...descriptor,
|
||||
value: wrapper,
|
||||
});
|
||||
} else {
|
||||
target.owner[target.key] = wrapper;
|
||||
}
|
||||
} catch {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
if (wrapper && target.owner[target.key] === wrapper) {
|
||||
if (descriptor) Object.defineProperty(target.owner, target.key, descriptor);
|
||||
else delete target.owner[target.key];
|
||||
}
|
||||
} catch {
|
||||
// Page-owned callback replacement wins over cleanup restoration.
|
||||
}
|
||||
};
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const addedScripts = (record: OneTalkMutationRecord): ScriptLike[] => {
|
||||
const scripts: ScriptLike[] = [];
|
||||
for (const node of Array.from(record.addedNodes ?? [])) {
|
||||
if (isScript(node)) scripts.push(node);
|
||||
if (!isRecord(node) || typeof node.querySelectorAll !== "function") continue;
|
||||
try {
|
||||
for (const script of Array.from(node.querySelectorAll("script"))) {
|
||||
if (isScript(script)) scripts.push(script);
|
||||
}
|
||||
} catch {
|
||||
// A detached or hostile node is not a reason to affect page behavior.
|
||||
}
|
||||
}
|
||||
return scripts;
|
||||
};
|
||||
|
||||
/** 默认实现只读取已有 script/callback;不会创建节点或发起网络请求。 */
|
||||
export const observeMatchingCrmCallbacks = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
onCallback: (hook: OneTalkCrmCallbackHook) => void,
|
||||
onDiagnostic?: (code: string) => void,
|
||||
): (() => void) => {
|
||||
const document = pageWindow.document;
|
||||
if (!document) return () => undefined;
|
||||
|
||||
const seenScripts = new WeakSet<object>();
|
||||
const hookedCallbacks = new Set<string>();
|
||||
const cleanups: Array<() => void> = [];
|
||||
let stopped = false;
|
||||
|
||||
const inspectScript = (scriptValue: unknown): void => {
|
||||
if (stopped || !isScript(scriptValue) || !isRecord(scriptValue)) return;
|
||||
if (seenScripts.has(scriptValue)) return;
|
||||
seenScripts.add(scriptValue);
|
||||
const source = scriptSource(scriptValue);
|
||||
if (!source || !isCrmScriptSource(source, pageWindow.location.href)) return;
|
||||
const callbackName = callbackNameFromScript(source, pageWindow.location.href);
|
||||
if (!callbackName || hookedCallbacks.has(callbackName)) return;
|
||||
const hook = callbackHookFor(pageWindow, callbackName);
|
||||
if (!hook) {
|
||||
onDiagnostic?.("crm_callback_unavailable");
|
||||
return;
|
||||
}
|
||||
hookedCallbacks.add(callbackName);
|
||||
onCallback(hook);
|
||||
};
|
||||
|
||||
try {
|
||||
for (const script of Array.from(document.querySelectorAll("script"))) {
|
||||
inspectScript(script);
|
||||
}
|
||||
} catch {
|
||||
onDiagnostic?.("crm_script_scan_failed");
|
||||
}
|
||||
|
||||
const MutationObserverCtor =
|
||||
pageWindow.MutationObserver ??
|
||||
(globalThis.MutationObserver as unknown as
|
||||
| (new (callback: (records: MutationRecord[]) => void) => OneTalkMutationObserver)
|
||||
| undefined);
|
||||
const root = ((document as unknown as { documentElement?: unknown }).documentElement ??
|
||||
document) as Node;
|
||||
if (MutationObserverCtor) {
|
||||
try {
|
||||
const observer = new MutationObserverCtor((records) => {
|
||||
if (stopped) return;
|
||||
for (const record of records) {
|
||||
for (const script of addedScripts(record)) inspectScript(script);
|
||||
}
|
||||
});
|
||||
observer.observe(root, { childList: true, subtree: true });
|
||||
cleanups.push(() => observer.disconnect());
|
||||
} catch {
|
||||
onDiagnostic?.("crm_script_observer_unavailable");
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
for (const cleanup of cleanups.splice(0)) cleanup();
|
||||
};
|
||||
};
|
||||
|
||||
/** 安装 callback hook;测试可注入此 seam 以固定尚未验证的真实 JSONP 形态。 */
|
||||
export const installOneTalkCrmProfileObserver = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
onObservation: OneTalkCrmObservationSink,
|
||||
options: OneTalkCrmObserverOptions = {},
|
||||
onDiagnostic?: (event: { code: string }) => void,
|
||||
): (() => void) => {
|
||||
let stopped = false;
|
||||
const cleanups: Array<() => void> = [];
|
||||
const onCallback = (hook: OneTalkCrmCallbackHook): void => {
|
||||
if (stopped) return;
|
||||
const cleanup = hook.install((args) => {
|
||||
if (stopped) return;
|
||||
for (const argument of args) {
|
||||
if (!isRecord(argument) || !isRecord(argument.buyerInfo)) continue;
|
||||
try {
|
||||
const observation = crmAvatarObservationFromBuyerInfo(argument.buyerInfo);
|
||||
if (observation) onObservation(observation);
|
||||
} catch {
|
||||
onDiagnostic?.({ code: "crm_profile_observation_failed" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
});
|
||||
cleanups.push(cleanup);
|
||||
};
|
||||
const observe = options.observeMatchingCallbacks ?? observeMatchingCrmCallbacks;
|
||||
try {
|
||||
cleanups.push(observe(pageWindow, onCallback, (code) => onDiagnostic?.({ code })));
|
||||
} catch {
|
||||
onDiagnostic?.({ code: "crm_observer_setup_failed" });
|
||||
}
|
||||
|
||||
const cleanup = (): void => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
for (const dispose of cleanups.splice(0).reverse()) dispose();
|
||||
};
|
||||
pageWindow.addEventListener("pagehide", cleanup);
|
||||
return cleanup;
|
||||
};
|
||||
@@ -2,10 +2,18 @@
|
||||
|
||||
import type { OneTalkContactProfile } from "@trade-message-center/onetalk-contract";
|
||||
|
||||
import { readChannelAccountId } from "../page-context.ts";
|
||||
import { readChannelAccountId, readConversationSelection } from "../page-context.ts";
|
||||
import type { OneTalkPageWindow } from "../model.ts";
|
||||
import { isRecord } from "../../../lib/guards.ts";
|
||||
import { profilesFromConversationMap } from "./model.ts";
|
||||
import {
|
||||
contactProfileWithCrmObservation,
|
||||
profilesFromConversationMap,
|
||||
type OneTalkCrmAvatarObservation,
|
||||
} from "./model.ts";
|
||||
import {
|
||||
installOneTalkCrmProfileObserver,
|
||||
type OneTalkCrmObserverOptions,
|
||||
} from "./crm-observer.ts";
|
||||
|
||||
type ProfileSink = (profiles: OneTalkContactProfile[], channelAccountId: string) => void;
|
||||
|
||||
@@ -18,6 +26,8 @@ export type OneTalkContactProfileObserver = {
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export type OneTalkContactProfileObserverOptions = OneTalkCrmObserverOptions;
|
||||
|
||||
const eventBusFor = (pageWindow: OneTalkPageWindow): PageEventBus | null => {
|
||||
return isRecord(pageWindow.EventBus) ? (pageWindow.EventBus as PageEventBus) : null;
|
||||
};
|
||||
@@ -31,10 +41,12 @@ export const installOneTalkContactProfileObserver = (
|
||||
pageWindow: OneTalkPageWindow,
|
||||
sink: ProfileSink,
|
||||
onDiagnostic?: (event: { code: string; fields?: readonly string[] }) => void,
|
||||
options: OneTalkContactProfileObserverOptions = {},
|
||||
): 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 => {
|
||||
if (stopped || profiles.length === 0 || channelAccountId.length === 0) return;
|
||||
@@ -47,11 +59,65 @@ export const installOneTalkContactProfileObserver = (
|
||||
onDiagnostic?.({ code: "profile_sink_failed" });
|
||||
}
|
||||
};
|
||||
const clearPageIdentity = (): void => {
|
||||
observedChannelAccountId = undefined;
|
||||
seen.clear();
|
||||
crmObservations.clear();
|
||||
};
|
||||
const profilesFromObservationMap = (value: unknown): OneTalkContactProfile[] => {
|
||||
return profilesFromConversationMap(value, now(), crmObservations);
|
||||
};
|
||||
const changedProfiles = (profiles: OneTalkContactProfile[]): OneTalkContactProfile[] => {
|
||||
return profiles.filter((profile) => {
|
||||
const previous = seen.get(profile.aliId);
|
||||
seen.set(profile.aliId, 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);
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
};
|
||||
const consumeCrmObservation = (crmObservation: OneTalkCrmAvatarObservation): void => {
|
||||
if (stopped) return;
|
||||
const channelAccountId = readChannelAccountId(pageWindow);
|
||||
if (!channelAccountId) {
|
||||
clearPageIdentity();
|
||||
onDiagnostic?.({ code: "profile_login_identity_missing" });
|
||||
return;
|
||||
}
|
||||
if (
|
||||
observedChannelAccountId !== undefined &&
|
||||
observedChannelAccountId !== channelAccountId
|
||||
) {
|
||||
clearPageIdentity();
|
||||
onDiagnostic?.({ code: "profile_login_identity_changed" });
|
||||
return;
|
||||
}
|
||||
observedChannelAccountId = channelAccountId;
|
||||
const selection = readConversationSelection(pageWindow);
|
||||
if (selection.kind !== "single") {
|
||||
onDiagnostic?.({ code: "crm_selected_conversation_ambiguous" });
|
||||
return;
|
||||
}
|
||||
const sdkProfile = selectedSdkProfile(selection.conversationId);
|
||||
if (!sdkProfile || !crmObservation) {
|
||||
onDiagnostic?.({ code: "crm_customer_identity_unmatched" });
|
||||
return;
|
||||
}
|
||||
if (!crmObservation.externalCustomerIds.includes(sdkProfile.aliId)) {
|
||||
onDiagnostic?.({ code: "crm_customer_identity_unmatched" });
|
||||
return;
|
||||
}
|
||||
crmObservations.set(sdkProfile.aliId, crmObservation);
|
||||
const nextProfile = contactProfileWithCrmObservation(sdkProfile, crmObservation, now());
|
||||
emit(changedProfiles([nextProfile]), channelAccountId);
|
||||
};
|
||||
const consumeUpdates = (value: unknown): void => {
|
||||
const channelAccountId = readChannelAccountId(pageWindow);
|
||||
if (!channelAccountId) {
|
||||
observedChannelAccountId = undefined;
|
||||
seen.clear();
|
||||
clearPageIdentity();
|
||||
onDiagnostic?.({ code: "profile_login_identity_missing" });
|
||||
return;
|
||||
}
|
||||
@@ -61,23 +127,27 @@ export const installOneTalkContactProfileObserver = (
|
||||
) {
|
||||
observedChannelAccountId = channelAccountId;
|
||||
seen.clear();
|
||||
crmObservations.clear();
|
||||
onDiagnostic?.({ code: "profile_login_identity_changed" });
|
||||
return;
|
||||
}
|
||||
observedChannelAccountId = channelAccountId;
|
||||
const profiles = profilesFromConversationMap(value, now());
|
||||
const changed = profiles.filter((profile) => {
|
||||
const previous = seen.get(profile.aliId);
|
||||
seen.set(profile.aliId, profile.profileFingerprint);
|
||||
return previous !== profile.profileFingerprint;
|
||||
});
|
||||
emit(changed, channelAccountId);
|
||||
emit(changedProfiles(profilesFromObservationMap(value)), channelAccountId);
|
||||
};
|
||||
const eventBus = eventBusFor(pageWindow);
|
||||
const unsubscribe = eventBus?.on?.("im-conversation-list:syncData", consumeUpdates);
|
||||
const disposeCrmObserver = installOneTalkCrmProfileObserver(
|
||||
pageWindow,
|
||||
consumeCrmObservation,
|
||||
options,
|
||||
onDiagnostic,
|
||||
);
|
||||
const onPageHide = (): void => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
unsubscribeFor(unsubscribe)?.();
|
||||
disposeCrmObserver();
|
||||
clearPageIdentity();
|
||||
};
|
||||
pageWindow.addEventListener("pagehide", onPageHide);
|
||||
|
||||
@@ -85,17 +155,16 @@ export const installOneTalkContactProfileObserver = (
|
||||
snapshot: () => {
|
||||
const channelAccountId = readChannelAccountId(pageWindow);
|
||||
if (stopped || !channelAccountId) {
|
||||
observedChannelAccountId = undefined;
|
||||
seen.clear();
|
||||
clearPageIdentity();
|
||||
onDiagnostic?.({ code: "profile_login_identity_missing" });
|
||||
return [];
|
||||
}
|
||||
if (observedChannelAccountId !== channelAccountId) seen.clear();
|
||||
if (observedChannelAccountId !== channelAccountId) {
|
||||
seen.clear();
|
||||
crmObservations.clear();
|
||||
}
|
||||
observedChannelAccountId = channelAccountId;
|
||||
const profiles = profilesFromConversationMap(
|
||||
pageWindow.__conversationListData__,
|
||||
now(),
|
||||
);
|
||||
const profiles = profilesFromObservationMap(pageWindow.__conversationListData__);
|
||||
for (const profile of profiles) seen.set(profile.aliId, profile.profileFingerprint);
|
||||
emit(profiles, channelAccountId);
|
||||
return profiles;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// 将 OneTalk 已加载会话条目清洗为跨边界联系人资料
|
||||
|
||||
import {
|
||||
isOneTalkAvatarUrl,
|
||||
type OneTalkContactProfile,
|
||||
ONETALK_CONTACT_PROFILE_STATUSES,
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
@@ -17,8 +18,27 @@ const PROFILE_FINGERPRINT_FIELDS = [
|
||||
"countryCode",
|
||||
"currentTimeZone",
|
||||
"serviceType",
|
||||
"avatarUrl",
|
||||
] as const;
|
||||
|
||||
export const CRM_AVATAR_FIELDS = [
|
||||
"avatarUrl",
|
||||
"fullPortrait",
|
||||
"profilePicUrl",
|
||||
"profilePictureUrl",
|
||||
"imgUrl",
|
||||
"portraitUrl",
|
||||
"headImg",
|
||||
"headerSmallPath",
|
||||
] as const;
|
||||
|
||||
export type OneTalkCrmAvatarObservation = {
|
||||
externalCustomerId: string;
|
||||
externalCustomerIds: readonly string[];
|
||||
avatarUrl: string | null;
|
||||
avatarConfirmed: true;
|
||||
};
|
||||
|
||||
const readString = (value: unknown): string | null => {
|
||||
if (typeof value === "string") return value.trim() || null;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
@@ -31,6 +51,68 @@ const readOptionalString = (value: unknown): string | null =>
|
||||
const readOptionalNumber = (value: unknown): number | null =>
|
||||
typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
|
||||
const readAvatarCandidate = (value: unknown): string | null => {
|
||||
if (typeof value !== "string") return null;
|
||||
return isOneTalkAvatarUrl(value) ? value : null;
|
||||
};
|
||||
|
||||
const avatarFromConversationRow = (
|
||||
contact: Record<string, unknown>,
|
||||
row: Record<string, unknown>,
|
||||
): string | null => {
|
||||
const candidates = [contact.fullPortrait, row.fullPortrait, row.avatarUrl, row.portraitUrl];
|
||||
for (const candidate of candidates) {
|
||||
const avatarUrl = readAvatarCandidate(candidate);
|
||||
if (avatarUrl) return avatarUrl;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const avatarFromBuyerInfo = (buyerInfo: Record<string, unknown>): string | null => {
|
||||
for (const field of CRM_AVATAR_FIELDS) {
|
||||
const avatarUrl = readAvatarCandidate(buyerInfo[field]);
|
||||
if (avatarUrl) return avatarUrl;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const customerIdentifiersFromBuyerInfo = (buyerInfo: Record<string, unknown>): string[] => {
|
||||
const identifiers = [buyerInfo.aliId, buyerInfo.externalCustomerId]
|
||||
.map(readString)
|
||||
.filter((value): value is string => value !== null);
|
||||
return [...new Set(identifiers)];
|
||||
};
|
||||
|
||||
type OneTalkContactProfileCore = Omit<
|
||||
OneTalkContactProfile,
|
||||
"observedAtMs" | "profileFingerprint" | "observationStatus"
|
||||
>;
|
||||
|
||||
const profileStatus = (profile: OneTalkContactProfileCore) =>
|
||||
Object.values(profile).every((value) => value !== null)
|
||||
? ONETALK_CONTACT_PROFILE_STATUSES[0]
|
||||
: "partial";
|
||||
|
||||
const profileWithMetadata = (
|
||||
profile: OneTalkContactProfileCore,
|
||||
observedAtMs: number,
|
||||
): OneTalkContactProfile => ({
|
||||
...profile,
|
||||
observedAtMs,
|
||||
profileFingerprint: fingerprintFor(profile),
|
||||
observationStatus: profileStatus(profile),
|
||||
});
|
||||
|
||||
const profileCore = (profile: OneTalkContactProfile): OneTalkContactProfileCore => {
|
||||
const {
|
||||
observedAtMs: _observedAtMs,
|
||||
profileFingerprint: _profileFingerprint,
|
||||
observationStatus: _observationStatus,
|
||||
...core
|
||||
} = profile;
|
||||
return core;
|
||||
};
|
||||
|
||||
const isGroupConversation = (row: Record<string, unknown>): boolean => {
|
||||
if (row.isGroup === true || row.groupId !== undefined || row.groupType !== undefined) {
|
||||
return true;
|
||||
@@ -64,6 +146,7 @@ const readProfileField = (
|
||||
export const profileFromConversationRow = (
|
||||
rowValue: unknown,
|
||||
observedAtMs: number,
|
||||
crmObservation?: OneTalkCrmAvatarObservation,
|
||||
): OneTalkContactProfile | null => {
|
||||
if (!isRecord(rowValue) || isGroupConversation(rowValue)) return null;
|
||||
const contact = isRecord(rowValue.contact) ? rowValue.contact : rowValue;
|
||||
@@ -86,26 +169,77 @@ export const profileFromConversationRow = (
|
||||
),
|
||||
currentTimeZone: readOptionalNumber(readProfileField(contact, rowValue, "currentTimeZone")),
|
||||
serviceType: readOptionalString(readProfileField(contact, rowValue, "serviceType")),
|
||||
avatarUrl: avatarFromConversationRow(contact, rowValue),
|
||||
} satisfies Omit<
|
||||
OneTalkContactProfile,
|
||||
"observedAtMs" | "profileFingerprint" | "observationStatus"
|
||||
>;
|
||||
const complete = Object.values(profile).every((value) => value !== null);
|
||||
return profileWithMetadata(mergeContactProfile(profile, crmObservation), observedAtMs);
|
||||
};
|
||||
|
||||
/** CRM 只在 buyerInfo 明确存在时确认头像字段;未命中客户身份即丢弃。 */
|
||||
export const crmAvatarObservationFromBuyerInfo = (
|
||||
buyerInfoValue: unknown,
|
||||
expectedCustomerId?: string,
|
||||
): OneTalkCrmAvatarObservation | null => {
|
||||
if (!isRecord(buyerInfoValue)) return null;
|
||||
const identifiers = customerIdentifiersFromBuyerInfo(buyerInfoValue);
|
||||
const externalCustomerId = expectedCustomerId
|
||||
? identifiers.find((identifier) => identifier === expectedCustomerId)
|
||||
: identifiers[0];
|
||||
if (!externalCustomerId) return null;
|
||||
return {
|
||||
...profile,
|
||||
observedAtMs,
|
||||
profileFingerprint: fingerprintFor(profile),
|
||||
observationStatus: complete ? ONETALK_CONTACT_PROFILE_STATUSES[0] : "partial",
|
||||
externalCustomerId,
|
||||
externalCustomerIds: identifiers,
|
||||
avatarUrl: avatarFromBuyerInfo(buyerInfoValue),
|
||||
avatarConfirmed: true,
|
||||
};
|
||||
};
|
||||
|
||||
/** CRM 已确认字段覆盖同一客户的 SDK 值;CRM 未确认时保留 SDK profile。 */
|
||||
export const mergeContactProfile = (
|
||||
sdkProfile: OneTalkContactProfileCore,
|
||||
crmObservation?: OneTalkCrmAvatarObservation,
|
||||
): OneTalkContactProfileCore => {
|
||||
if (
|
||||
!crmObservation?.avatarConfirmed ||
|
||||
!crmObservation.externalCustomerIds.includes(sdkProfile.aliId)
|
||||
) {
|
||||
return sdkProfile;
|
||||
}
|
||||
return { ...sdkProfile, avatarUrl: crmObservation.avatarUrl };
|
||||
};
|
||||
|
||||
/** 将 source-local CRM 结果合并为唯一可跨边界的 profile。 */
|
||||
export const contactProfileWithCrmObservation = (
|
||||
sdkProfile: OneTalkContactProfile,
|
||||
crmObservation?: OneTalkCrmAvatarObservation,
|
||||
observedAtMs = sdkProfile.observedAtMs,
|
||||
): OneTalkContactProfile => {
|
||||
return profileWithMetadata(
|
||||
mergeContactProfile(profileCore(sdkProfile), crmObservation),
|
||||
observedAtMs,
|
||||
);
|
||||
};
|
||||
|
||||
export const profilesFromConversationMap = (
|
||||
value: unknown,
|
||||
observedAtMs: number,
|
||||
crmObservations: ReadonlyMap<string, OneTalkCrmAvatarObservation> = new Map(),
|
||||
): OneTalkContactProfile[] => {
|
||||
if (!isRecord(value)) return [];
|
||||
return Object.values(value).flatMap((row) => {
|
||||
const profile = profileFromConversationRow(row, observedAtMs);
|
||||
const profile = profileFromConversationRow(
|
||||
row,
|
||||
observedAtMs,
|
||||
isRecord(row)
|
||||
? crmObservations.get(
|
||||
readString(
|
||||
readProfileField(isRecord(row.contact) ? row.contact : row, row, "aliId"),
|
||||
) ?? "",
|
||||
)
|
||||
: undefined,
|
||||
);
|
||||
return profile === null ? [] : [profile];
|
||||
});
|
||||
};
|
||||
|
||||
@@ -13,8 +13,20 @@ export type OneTalkPageWindow = {
|
||||
/** OneTalk's already-loaded conversation list; read only in MAIN world. */
|
||||
__conversationListData__?: unknown;
|
||||
EventBus?: unknown;
|
||||
MutationObserver?: OneTalkMutationObserverConstructor;
|
||||
};
|
||||
|
||||
export type OneTalkMutationObserverConstructor = new (
|
||||
callback: (records: MutationRecord[]) => void,
|
||||
) => OneTalkMutationObserver;
|
||||
|
||||
export type OneTalkMutationObserver = {
|
||||
observe: (target: Node, options?: MutationObserverInit) => void;
|
||||
disconnect: () => void;
|
||||
};
|
||||
|
||||
export type OneTalkMutationRecord = MutationRecord;
|
||||
|
||||
/** 判断值是否符合 OneTalk 账号标识形状。 */
|
||||
export const isAccountId = (value: unknown): value is OneTalkAccountId => {
|
||||
return (
|
||||
|
||||
@@ -73,15 +73,17 @@ const installCommandConsumer = (
|
||||
pageWindow: OneTalkPageBridgeWindow,
|
||||
origin: string,
|
||||
onCommand: OneTalkPageCommandHandler,
|
||||
isActive: () => boolean,
|
||||
): void => {
|
||||
pageWindow.addEventListener("message", (event) => {
|
||||
try {
|
||||
if (event.source !== pageWindow || event.origin !== origin) return;
|
||||
if (!isActive() || event.source !== pageWindow || event.origin !== origin) return;
|
||||
const message = decodeOneTalkPageMessage(event.data);
|
||||
if (!message || !isOneTalkIsolatedToMainMessage(message)) return;
|
||||
|
||||
void Promise.resolve(onCommand(message))
|
||||
.then((result) => {
|
||||
if (!isActive()) return;
|
||||
const resultMessage: OneTalkPageCommandResultMessage =
|
||||
createOneTalkPageCommandResultMessage(message.requestId, result);
|
||||
postPageMessage(pageWindow, origin, resultMessage);
|
||||
@@ -152,8 +154,8 @@ export const installOneTalkMainPageBridge = (
|
||||
try {
|
||||
const origin = pageOrigin(pageWindow);
|
||||
if (!origin) return;
|
||||
if (onCommand) installCommandConsumer(pageWindow, origin, onCommand);
|
||||
let stopped = false;
|
||||
if (onCommand) installCommandConsumer(pageWindow, origin, onCommand, () => !stopped);
|
||||
let attempts = 0;
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const retryDelay =
|
||||
|
||||
@@ -62,6 +62,7 @@ const PROFILE_FIELD_NAMES = [
|
||||
"countryCode",
|
||||
"currentTimeZone",
|
||||
"serviceType",
|
||||
"avatarUrl",
|
||||
"observedAtMs",
|
||||
"profileFingerprint",
|
||||
"observationStatus",
|
||||
|
||||
@@ -54,6 +54,22 @@ const accepted = () => ({
|
||||
payload: { authorizationVersion: "version-1", permissions: ["read"] },
|
||||
});
|
||||
|
||||
const profile = {
|
||||
conversationId: "conversation-1",
|
||||
aliId: "2208314000798",
|
||||
accountId: "243340382",
|
||||
loginId: "hzhago",
|
||||
name: "Heena Liu",
|
||||
companyName: "Hago",
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.test/avatar/customer-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "v1-profile",
|
||||
observationStatus: "confirmed",
|
||||
};
|
||||
|
||||
test("authenticates, receives anchors, uploads observations, and heartbeats", async () => {
|
||||
const sockets = [];
|
||||
const frames = [];
|
||||
@@ -151,6 +167,27 @@ test("does not accept frames from another Bright scope", () => {
|
||||
assert.equal(errors.at(-1).code, "scope_mismatch");
|
||||
});
|
||||
|
||||
test("sends the approved profile including avatarUrl through the existing Bright frame", () => {
|
||||
const socket = new FakeSocket("wss://bright.example/ws");
|
||||
const client = createOneTalkBrightClient({
|
||||
url: socket.url,
|
||||
scope,
|
||||
binding: "binding-1",
|
||||
webSocket: () => socket,
|
||||
autoReconnect: false,
|
||||
});
|
||||
client.connect();
|
||||
socket.open();
|
||||
socket.receive(accepted());
|
||||
|
||||
assert.equal(
|
||||
client.sendContactProfiles({ profiles: [profile], requestId: "profile-1" }),
|
||||
"profile-1",
|
||||
);
|
||||
assert.deepEqual(socket.sent.at(-1).payload.profiles, [profile]);
|
||||
assert.equal(socket.sent.at(-1).payload.profiles[0].avatarUrl, profile.avatarUrl);
|
||||
});
|
||||
|
||||
test("keeps ws.error visible and uses a browser-valid client close code", () => {
|
||||
const socket = new FakeSocket("wss://bright.example/ws?credential=secret#fragment");
|
||||
const diagnostics = [];
|
||||
|
||||
@@ -51,11 +51,83 @@ class EmptyStore {
|
||||
}
|
||||
}
|
||||
|
||||
class ProfileStore {
|
||||
constructor() {
|
||||
this.records = new Map();
|
||||
}
|
||||
|
||||
async getProfile(channelAccountId, aliId) {
|
||||
return this.records.get(JSON.stringify([channelAccountId, aliId])) ?? null;
|
||||
}
|
||||
|
||||
async listPendingProfiles(channelAccountId) {
|
||||
return [...this.records.values()].filter(
|
||||
(record) =>
|
||||
record.pending !== undefined &&
|
||||
(channelAccountId === undefined || record.channelAccountId === channelAccountId),
|
||||
);
|
||||
}
|
||||
|
||||
async putPendingProfile(channelAccountId, profile) {
|
||||
const key = JSON.stringify([channelAccountId, profile.aliId]);
|
||||
const current = this.records.get(key);
|
||||
if (current?.lastUploadedFingerprint === profile.profileFingerprint && !current.pending) {
|
||||
return current;
|
||||
}
|
||||
const record = {
|
||||
key,
|
||||
channelAccountId,
|
||||
aliId: profile.aliId,
|
||||
lastUploadedFingerprint: current?.lastUploadedFingerprint ?? null,
|
||||
pending: {
|
||||
profile: { ...profile },
|
||||
fingerprint: profile.profileFingerprint,
|
||||
observedAtMs: profile.observedAtMs,
|
||||
},
|
||||
updatedAt: 100,
|
||||
};
|
||||
this.records.set(key, record);
|
||||
return record;
|
||||
}
|
||||
|
||||
async markProfileUploaded({ channelAccountId, aliId, fingerprint, uploadedAt }) {
|
||||
const key = JSON.stringify([channelAccountId, aliId]);
|
||||
const current = this.records.get(key);
|
||||
if (current?.pending?.fingerprint !== fingerprint) return false;
|
||||
this.records.set(key, {
|
||||
...current,
|
||||
pending: undefined,
|
||||
lastUploadedFingerprint: fingerprint,
|
||||
updatedAt: uploadedAt,
|
||||
lastUploadedAt: uploadedAt,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const profile = {
|
||||
conversationId: "conversation-1",
|
||||
aliId: "2208314000798",
|
||||
accountId: "243340382",
|
||||
loginId: "hzhago",
|
||||
name: "Heena Liu",
|
||||
companyName: "Hago",
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.test/avatar/customer-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "v1-profile",
|
||||
observationStatus: "confirmed",
|
||||
};
|
||||
|
||||
class FakeBright {
|
||||
constructor(options) {
|
||||
this.options = options;
|
||||
this.state = { status: "idle", permissions: [] };
|
||||
this.confirmations = [];
|
||||
this.profileSends = [];
|
||||
this.statusListeners = [];
|
||||
}
|
||||
|
||||
connect() {}
|
||||
@@ -74,8 +146,11 @@ class FakeBright {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
subscribeStatus() {
|
||||
return () => {};
|
||||
subscribeStatus(listener) {
|
||||
this.statusListeners.push(listener);
|
||||
return () => {
|
||||
this.statusListeners = this.statusListeners.filter((item) => item !== listener);
|
||||
};
|
||||
}
|
||||
|
||||
sendConversationDiscovered() {
|
||||
@@ -90,13 +165,23 @@ class FakeBright {
|
||||
return false;
|
||||
}
|
||||
|
||||
sendContactProfiles(input) {
|
||||
this.profileSends.push(input);
|
||||
return `profile-${this.profileSends.length}`;
|
||||
}
|
||||
|
||||
emitStatus(status) {
|
||||
this.state = { status, permissions: ["read"] };
|
||||
for (const listener of this.statusListeners) listener(this.state);
|
||||
}
|
||||
|
||||
sendSendConfirmation(input) {
|
||||
this.confirmations.push(input);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const createSession = (pageResult, diagnostics) => {
|
||||
const createSession = (pageResult, diagnostics, profileStore) => {
|
||||
let bright;
|
||||
const routeCalls = [];
|
||||
const session = new OneTalkConfiguredSyncSession({
|
||||
@@ -105,6 +190,7 @@ const createSession = (pageResult, diagnostics) => {
|
||||
return bright;
|
||||
},
|
||||
createStore: () => new EmptyStore(),
|
||||
...(profileStore === undefined ? {} : { createProfileStore: () => profileStore }),
|
||||
pageRuntime: {
|
||||
routePageCommand: async (route) => {
|
||||
routeCalls.push(route);
|
||||
@@ -187,3 +273,36 @@ test("forwards a complete sent page message as confirmed_sent and preserves corr
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("creates an account-scoped profile coordinator that carries avatarUrl", async () => {
|
||||
const diagnostics = [];
|
||||
const profileStore = new ProfileStore();
|
||||
const { session, getBright } = createSession(
|
||||
{ status: "completed" },
|
||||
diagnostics,
|
||||
profileStore,
|
||||
);
|
||||
|
||||
await session.configure(config);
|
||||
const active = session.getActive();
|
||||
assert.ok(active.profile);
|
||||
await active.profile.observe([profile]);
|
||||
assert.deepEqual(getBright().profileSends, []);
|
||||
getBright().emitStatus("authenticated");
|
||||
await flushPromises();
|
||||
|
||||
assert.deepEqual(getBright().profileSends, [
|
||||
{ profiles: [profile], requestId: "profile-batch-1" },
|
||||
]);
|
||||
assert.equal(
|
||||
(await profileStore.getProfile("account-1", profile.aliId)).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,
|
||||
profile.avatarUrl,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -4,7 +4,12 @@ import test from "node:test";
|
||||
import { createOneTalkContactProfileCoordinator } from "../src/onetalk/service-worker/contact-profile-coordinator.ts";
|
||||
|
||||
const scope = { channelAccountId: "login-account-1", deviceId: "device-1" };
|
||||
const profile = (name, fingerprint, aliId = "2208314000798") => ({
|
||||
const profile = (
|
||||
name,
|
||||
fingerprint,
|
||||
aliId = "2208314000798",
|
||||
avatarUrl = "https://cdn.example.test/avatar/customer-1.jpg",
|
||||
) => ({
|
||||
conversationId: "conversation-1",
|
||||
aliId,
|
||||
accountId: "243340382",
|
||||
@@ -14,12 +19,13 @@ const profile = (name, fingerprint, aliId = "2208314000798") => ({
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl,
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: fingerprint,
|
||||
observationStatus: "confirmed",
|
||||
});
|
||||
|
||||
const createFixture = ({ holdFirstList = false } = {}) => {
|
||||
const createFixture = ({ holdFirstList = false, onDiagnostic } = {}) => {
|
||||
const records = new Map();
|
||||
const sent = [];
|
||||
let listCount = 0;
|
||||
@@ -92,15 +98,34 @@ const createFixture = ({ holdFirstList = false } = {}) => {
|
||||
store,
|
||||
bright,
|
||||
now: () => 1_700_000_000_100,
|
||||
onDiagnostic,
|
||||
});
|
||||
return { coordinator, records, sent, firstListStarted, releaseList: () => releaseList?.() };
|
||||
};
|
||||
|
||||
test("writes pending before send, replaces pending profile, and ignores stale ACK", async () => {
|
||||
const fixture = createFixture();
|
||||
const first = profile("First", "v1-first");
|
||||
const second = profile("Second", "v1-second");
|
||||
test("writes pending before send, replaces pending avatar, and ignores stale ACK", async () => {
|
||||
const diagnostics = [];
|
||||
const fixture = createFixture({ onDiagnostic: (event) => diagnostics.push(event) });
|
||||
const first = profile(
|
||||
"First",
|
||||
"v1-first",
|
||||
"2208314000798",
|
||||
"https://cdn.example.test/avatar/v1.jpg",
|
||||
);
|
||||
const second = profile(
|
||||
"Second",
|
||||
"v1-second",
|
||||
"2208314000798",
|
||||
"https://cdn.example.test/avatar/v2.jpg",
|
||||
);
|
||||
|
||||
await fixture.coordinator.observe([first]);
|
||||
assert.equal(fixture.sent.length, 1);
|
||||
const observed = diagnostics.find((event) => event.event === "profile_observed");
|
||||
const sent = diagnostics.find((event) => event.event === "profile_send");
|
||||
assert.ok(observed.fieldNames.includes("avatarUrl"));
|
||||
assert.ok(sent.fieldNames.includes("avatarUrl"));
|
||||
assert.equal(JSON.stringify(diagnostics).includes(first.avatarUrl), false);
|
||||
await fixture.coordinator.observe([first]);
|
||||
assert.equal(fixture.sent.length, 1);
|
||||
await fixture.coordinator.observe([second]);
|
||||
@@ -120,6 +145,11 @@ test("writes pending before send, replaces pending profile, and ignores stale AC
|
||||
.name,
|
||||
"Second",
|
||||
);
|
||||
assert.equal(
|
||||
fixture.records.get(JSON.stringify([scope.channelAccountId, first.aliId])).pending.profile
|
||||
.avatarUrl,
|
||||
"https://cdn.example.test/avatar/v2.jpg",
|
||||
);
|
||||
|
||||
fixture.coordinator.handleFrame({
|
||||
type: "contact.profile.ack",
|
||||
@@ -190,6 +220,65 @@ test("clears only in-memory request correlation on Bright close and page disconn
|
||||
);
|
||||
});
|
||||
|
||||
test("retries URL to null and null to URL transitions after reconnect", async () => {
|
||||
const fixture = createFixture();
|
||||
const url = profile(
|
||||
"Customer",
|
||||
"v1-url",
|
||||
"2208314000798",
|
||||
"https://cdn.example.test/avatar/v1.jpg",
|
||||
);
|
||||
const cleared = profile("Customer", "v2-null", "2208314000798", null);
|
||||
const restored = profile(
|
||||
"Customer",
|
||||
"v3-url",
|
||||
"2208314000798",
|
||||
"https://cdn.example.test/avatar/v3.jpg",
|
||||
);
|
||||
|
||||
await fixture.coordinator.observe([url]);
|
||||
fixture.coordinator.handleStatus({ status: "offline", permissions: [] });
|
||||
fixture.coordinator.handleStatus({ status: "authenticated", permissions: [] });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(
|
||||
fixture.sent.map((entry) => entry.profiles[0].avatarUrl),
|
||||
[url.avatarUrl, url.avatarUrl],
|
||||
);
|
||||
|
||||
await fixture.coordinator.observe([cleared]);
|
||||
await fixture.coordinator.observe([restored]);
|
||||
assert.deepEqual(
|
||||
fixture.sent.slice(2).map((entry) => entry.profiles[0].avatarUrl),
|
||||
[null, restored.avatarUrl],
|
||||
);
|
||||
|
||||
fixture.coordinator.handleFrame({
|
||||
type: "contact.profile.ack",
|
||||
protocolVersion: 2,
|
||||
connectionType: "plugin",
|
||||
requestId: "request-3",
|
||||
scope,
|
||||
payload: { status: "delivered", profileCount: 1 },
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const record = fixture.records.get(JSON.stringify([scope.channelAccountId, restored.aliId]));
|
||||
assert.equal(record.pending.profile.avatarUrl, restored.avatarUrl);
|
||||
assert.equal(record.lastUploadedFingerprint, null);
|
||||
|
||||
fixture.coordinator.handleFrame({
|
||||
type: "contact.profile.ack",
|
||||
protocolVersion: 2,
|
||||
connectionType: "plugin",
|
||||
requestId: "request-4",
|
||||
scope,
|
||||
payload: { status: "delivered", profileCount: 1 },
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const uploaded = fixture.records.get(JSON.stringify([scope.channelAccountId, restored.aliId]));
|
||||
assert.equal(uploaded.pending, undefined);
|
||||
assert.equal(uploaded.lastUploadedFingerprint, restored.profileFingerprint);
|
||||
});
|
||||
|
||||
test("schedules one bounded follow-up flush when an update races the first flush", async () => {
|
||||
const fixture = createFixture({ holdFirstList: true });
|
||||
const first = profile("First", "v1-first");
|
||||
|
||||
@@ -2,9 +2,13 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { installOneTalkContactProfileObserver } from "../src/onetalk/main-page/contact-observer/entry.ts";
|
||||
import { profileFromConversationRow } from "../src/onetalk/main-page/contact-observer/model.ts";
|
||||
import {
|
||||
crmAvatarObservationFromBuyerInfo,
|
||||
profileFromConversationRow,
|
||||
} from "../src/onetalk/main-page/contact-observer/model.ts";
|
||||
import { installOneTalkCrmProfileObserver } from "../src/onetalk/main-page/contact-observer/crm-observer.ts";
|
||||
|
||||
const row = (name = "Heena Liu") => ({
|
||||
const row = (name = "Heena Liu", avatar = "https://cdn.example.test/sdk-avatar.jpg") => ({
|
||||
cid: "conversation-1",
|
||||
aliId: 2208314000798,
|
||||
accountId: 243340382,
|
||||
@@ -14,6 +18,7 @@ const row = (name = "Heena Liu") => ({
|
||||
complianceCountryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: avatar,
|
||||
chatToken: "secret-chat-token",
|
||||
aliIdEncrypt: "secret-encrypted-id",
|
||||
});
|
||||
@@ -25,6 +30,9 @@ const createPage = (initial) => {
|
||||
location: { href: "https://onetalk.alibaba.com/message/weblitePWA.htm" },
|
||||
currentUserAccountId: "login-account-1",
|
||||
__conversationListData__: initial,
|
||||
document: {
|
||||
querySelectorAll: () => [],
|
||||
},
|
||||
EventBus: {
|
||||
on: (_event, next) => {
|
||||
listener = next;
|
||||
@@ -58,6 +66,7 @@ test("constructs a whitelist profile without leaking sensitive row fields", () =
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: profile.profileFingerprint,
|
||||
observationStatus: "confirmed",
|
||||
avatarUrl: "https://cdn.example.test/sdk-avatar.jpg",
|
||||
});
|
||||
assert.equal(JSON.stringify(profile).includes("secret"), false);
|
||||
});
|
||||
@@ -125,3 +134,300 @@ test("drops syncData without login identity and labels switched-account updates"
|
||||
assert.equal(batches[1].channelAccountId, "login-account-2");
|
||||
assert.equal(batches[1].profiles[0].name, "Before New Hello");
|
||||
});
|
||||
|
||||
test("uses the SDK avatar priority, skips invalid candidates, and fingerprints avatar changes", () => {
|
||||
const base = row();
|
||||
const contactAvatar = "https://cdn.example.test/contact.jpg";
|
||||
const rowAvatar = "https://cdn.example.test/row.jpg";
|
||||
const portraitAvatar = "https://cdn.example.test/portrait.jpg";
|
||||
const withCandidates = {
|
||||
...base,
|
||||
contact: {
|
||||
aliId: base.aliId,
|
||||
fullPortrait: "a",
|
||||
},
|
||||
fullPortrait: rowAvatar,
|
||||
avatarUrl: "javascript:alert(1)",
|
||||
portraitUrl: portraitAvatar,
|
||||
};
|
||||
assert.equal(
|
||||
profileFromConversationRow(withCandidates, 1_700_000_000_000).avatarUrl,
|
||||
rowAvatar,
|
||||
);
|
||||
assert.equal(
|
||||
profileFromConversationRow(
|
||||
{
|
||||
...withCandidates,
|
||||
contact: { ...withCandidates.contact, fullPortrait: contactAvatar },
|
||||
},
|
||||
1_700_000_000_000,
|
||||
).avatarUrl,
|
||||
contactAvatar,
|
||||
);
|
||||
assert.equal(
|
||||
profileFromConversationRow(
|
||||
{ ...withCandidates, fullPortrait: " https://cdn.example.test/invalid-space.jpg " },
|
||||
1_700_000_000_000,
|
||||
).avatarUrl,
|
||||
portraitAvatar,
|
||||
);
|
||||
|
||||
const first = profileFromConversationRow(base, 1_700_000_000_000);
|
||||
const second = profileFromConversationRow(
|
||||
{ ...base, avatarUrl: "https://cdn.example.test/changed.jpg" },
|
||||
1_700_000_000_000,
|
||||
);
|
||||
assert.notEqual(first.profileFingerprint, second.profileFingerprint);
|
||||
|
||||
const externalIdObservation = crmAvatarObservationFromBuyerInfo({
|
||||
aliId: "other-id",
|
||||
externalCustomerId: base.aliId,
|
||||
avatarUrl: "https://cdn.example.test/external-id.jpg",
|
||||
});
|
||||
assert.equal(
|
||||
profileFromConversationRow(base, 1_700_000_000_000, externalIdObservation).avatarUrl,
|
||||
"https://cdn.example.test/external-id.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
test("snapshot and syncData share the same SDK extraction and avatar fingerprint", () => {
|
||||
const fixture = createPage({ first: row() });
|
||||
const batches = [];
|
||||
const observer = installOneTalkContactProfileObserver(fixture.page, (profiles) => {
|
||||
batches.push(profiles);
|
||||
});
|
||||
|
||||
const snapshot = observer.snapshot();
|
||||
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");
|
||||
});
|
||||
|
||||
const crmSeam = (capture) => ({
|
||||
observeMatchingCallbacks: (_page, onCallback) => {
|
||||
onCallback({
|
||||
name: "injected.crm.callback",
|
||||
install: (listener) => {
|
||||
capture.listener = listener;
|
||||
return () => {
|
||||
capture.listener = undefined;
|
||||
};
|
||||
},
|
||||
});
|
||||
return () => {
|
||||
capture.observerDisposed = true;
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const selectedDocument = (...conversationIds) => ({
|
||||
querySelectorAll: (selector) => {
|
||||
assert.equal(selector, ".contact-item-container.selected[data-cid]");
|
||||
return conversationIds.map((conversationId) => ({
|
||||
getAttribute: (name) => (name === "data-cid" ? conversationId : null),
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
test("passively captures buyerInfo from an injected callback, preserves callback semantics, and merges CRM null", () => {
|
||||
const fixture = createPage({ first: row() });
|
||||
fixture.page.document = selectedDocument("conversation-1");
|
||||
const capture = {};
|
||||
const batches = [];
|
||||
const observer = installOneTalkContactProfileObserver(
|
||||
fixture.page,
|
||||
(profiles) => batches.push(profiles),
|
||||
undefined,
|
||||
crmSeam(capture),
|
||||
);
|
||||
observer.snapshot();
|
||||
const callbackArgs = [
|
||||
{
|
||||
buyerInfo: {
|
||||
aliId: 2208314000798,
|
||||
profilePicUrl: "a",
|
||||
headerSmallPath: "https://cdn.example.test/crm-avatar.jpg",
|
||||
secReqToken: "secret-token",
|
||||
},
|
||||
rawRow: { ctoken: "secret-ctoken" },
|
||||
},
|
||||
];
|
||||
capture.listener(callbackArgs);
|
||||
assert.equal(batches.length, 2);
|
||||
assert.equal(batches.at(-1)[0].avatarUrl, "https://cdn.example.test/crm-avatar.jpg");
|
||||
assert.equal(JSON.stringify(batches.at(-1)).includes("secret"), false);
|
||||
|
||||
capture.listener([{ buyerInfo: { externalCustomerId: "2208314000798", fullPortrait: "a" } }]);
|
||||
assert.equal(batches.at(-1)[0].avatarUrl, null);
|
||||
assert.equal(batches.at(-1)[0].observationStatus, "partial");
|
||||
fixture.page.emitSyncData({
|
||||
first: row("Heena Liu", "https://cdn.example.test/sdk-reappeared.jpg"),
|
||||
});
|
||||
assert.equal(batches.at(-1)[0].avatarUrl, null);
|
||||
assert.equal(crmAvatarObservationFromBuyerInfo({ aliId: 2208314000798 }).avatarUrl, null);
|
||||
for (const field of [
|
||||
"avatarUrl",
|
||||
"fullPortrait",
|
||||
"profilePicUrl",
|
||||
"profilePictureUrl",
|
||||
"imgUrl",
|
||||
"portraitUrl",
|
||||
"headImg",
|
||||
"headerSmallPath",
|
||||
]) {
|
||||
assert.equal(
|
||||
crmAvatarObservationFromBuyerInfo({
|
||||
aliId: 2208314000798,
|
||||
[field]: "https://cdn.example.test/whitelist.jpg",
|
||||
}).avatarUrl,
|
||||
"https://cdn.example.test/whitelist.jpg",
|
||||
);
|
||||
}
|
||||
observer.dispose();
|
||||
assert.equal(capture.observerDisposed, true);
|
||||
});
|
||||
|
||||
test("matches existing CRM scripts passively, preserves the original callback, and cleans up", () => {
|
||||
const fixture = createPage({ first: row() });
|
||||
const originalArgs = [];
|
||||
const originalCallback = (...args) => {
|
||||
originalArgs.push(args);
|
||||
return "callback-result";
|
||||
};
|
||||
fixture.page.crmJsonp = originalCallback;
|
||||
let mutationCallback;
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
mutationCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
disconnect() {
|
||||
mutationCallback = undefined;
|
||||
}
|
||||
}
|
||||
fixture.page.MutationObserver = FakeMutationObserver;
|
||||
const script = {
|
||||
tagName: "SCRIPT",
|
||||
src: "https://crm.example.test/queryCustomerInfo?callback=crmJsonp",
|
||||
getAttribute: (name) => (name === "src" ? script.src : null),
|
||||
};
|
||||
fixture.page.document = {
|
||||
querySelectorAll: (selector) => {
|
||||
assert.equal(selector, "script");
|
||||
return [script];
|
||||
},
|
||||
};
|
||||
const buyers = [];
|
||||
const dispose = installOneTalkCrmProfileObserver(fixture.page, (buyerInfo) => {
|
||||
buyers.push(buyerInfo);
|
||||
});
|
||||
|
||||
const callbackPayload = {
|
||||
buyerInfo: { aliId: 2208314000798, avatarUrl: "https://cdn.example.test/crm.jpg" },
|
||||
ctoken: "secret-ctoken",
|
||||
};
|
||||
assert.equal(fixture.page.crmJsonp(callbackPayload), "callback-result");
|
||||
assert.deepEqual(originalArgs, [[callbackPayload]]);
|
||||
assert.equal(buyers[0].avatarUrl, callbackPayload.buyerInfo.avatarUrl);
|
||||
assert.equal(JSON.stringify(buyers[0]).includes("secret"), false);
|
||||
assert.equal(mutationCallback !== undefined, true);
|
||||
|
||||
dispose();
|
||||
assert.equal(fixture.page.crmJsonp, originalCallback);
|
||||
fixture.page.crmJsonp(callbackPayload);
|
||||
assert.equal(buyers.length, 1);
|
||||
assert.equal(mutationCallback, undefined);
|
||||
});
|
||||
|
||||
test("fails closed for missing, switched, or ambiguous selected customer context", () => {
|
||||
const fixture = createPage({ first: row() });
|
||||
const capture = {};
|
||||
const batches = [];
|
||||
installOneTalkContactProfileObserver(
|
||||
fixture.page,
|
||||
(profiles) => batches.push(profiles),
|
||||
undefined,
|
||||
crmSeam(capture),
|
||||
).snapshot();
|
||||
|
||||
fixture.page.document = selectedDocument();
|
||||
capture.listener([
|
||||
{ buyerInfo: { aliId: 2208314000798, avatarUrl: "https://cdn.example.test/a" } },
|
||||
]);
|
||||
fixture.page.document = selectedDocument("conversation-1", "conversation-1");
|
||||
capture.listener([
|
||||
{ buyerInfo: { aliId: 2208314000798, avatarUrl: "https://cdn.example.test/b" } },
|
||||
]);
|
||||
fixture.page.document = selectedDocument("conversation-1");
|
||||
fixture.page.currentUserAccountId = undefined;
|
||||
fixture.page.IcbuIM = { UserUtil: { currentUser: { accountId: "" } } };
|
||||
capture.listener([
|
||||
{ buyerInfo: { aliId: 2208314000798, avatarUrl: "https://cdn.example.test/c" } },
|
||||
]);
|
||||
assert.equal(batches.length, 1);
|
||||
});
|
||||
|
||||
test("observes matching scripts added later without touching non-CRM scripts", () => {
|
||||
const fixture = createPage({ first: row() });
|
||||
const originalCallback = () => "tag-result";
|
||||
fixture.page.crmTagJsonp = originalCallback;
|
||||
const scripts = [];
|
||||
let mutationCallback;
|
||||
class FakeMutationObserver {
|
||||
constructor(callback) {
|
||||
mutationCallback = callback;
|
||||
}
|
||||
|
||||
observe() {}
|
||||
|
||||
disconnect() {
|
||||
mutationCallback = undefined;
|
||||
}
|
||||
}
|
||||
fixture.page.MutationObserver = FakeMutationObserver;
|
||||
fixture.page.document = { querySelectorAll: () => scripts };
|
||||
const buyers = [];
|
||||
const observer = installOneTalkCrmProfileObserver(fixture.page, (buyerInfo) => {
|
||||
buyers.push(buyerInfo);
|
||||
});
|
||||
const nonCrm = {
|
||||
tagName: "SCRIPT",
|
||||
src: "https://example.test/querySomething?callback=crmTagJsonp",
|
||||
};
|
||||
const crm = {
|
||||
tagName: "SCRIPT",
|
||||
src: "https://example.test/queryCustomerTag?callback=crmTagJsonp",
|
||||
};
|
||||
mutationCallback([{ addedNodes: [nonCrm, crm] }]);
|
||||
assert.equal(typeof fixture.page.crmTagJsonp, "function");
|
||||
fixture.page.crmTagJsonp({ buyerInfo: { aliId: 2208314000798 } });
|
||||
assert.equal(buyers.length, 1);
|
||||
assert.equal(fixture.page.crmTagJsonp(), "tag-result");
|
||||
observer();
|
||||
assert.equal(fixture.page.crmTagJsonp, originalCallback);
|
||||
});
|
||||
|
||||
test("does not hook a non-CRM script that only mentions a target name in a query value", () => {
|
||||
const fixture = createPage({ first: row() });
|
||||
const originalCallback = () => "third-party-result";
|
||||
fixture.page.thirdPartyCallback = originalCallback;
|
||||
const script = {
|
||||
tagName: "SCRIPT",
|
||||
src: "https://third-party.example/payload?next=queryCustomerInfo&callback=thirdPartyCallback",
|
||||
getAttribute: (name) => (name === "src" ? script.src : null),
|
||||
};
|
||||
fixture.page.document = {
|
||||
querySelectorAll: () => [script],
|
||||
};
|
||||
|
||||
const observer = installOneTalkCrmProfileObserver(fixture.page, () => undefined);
|
||||
assert.equal(fixture.page.thirdPartyCallback, originalCallback);
|
||||
assert.equal(fixture.page.thirdPartyCallback(), "third-party-result");
|
||||
observer();
|
||||
});
|
||||
|
||||
@@ -9,7 +9,10 @@ import {
|
||||
contactProfileKey,
|
||||
} from "../src/onetalk/service-worker/storage.ts";
|
||||
|
||||
const profile = (fingerprint = "v1-profile") => ({
|
||||
const profile = (
|
||||
fingerprint = "v1-profile",
|
||||
avatarUrl = "https://cdn.example.test/avatar/customer-1.jpg",
|
||||
) => ({
|
||||
conversationId: "conversation-1",
|
||||
aliId: "2208314000798",
|
||||
accountId: "243340382",
|
||||
@@ -19,6 +22,7 @@ const profile = (fingerprint = "v1-profile") => ({
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl,
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: fingerprint,
|
||||
observationStatus: "confirmed",
|
||||
@@ -273,3 +277,37 @@ test("rejects on transaction error and preserves the pending profile", async ()
|
||||
.records.get(contactProfileKey("account-1", profile().aliId));
|
||||
assert.equal(record.pending.fingerprint, "v1-profile");
|
||||
});
|
||||
|
||||
test("keeps the latest avatar value and explicit null in the durable profile record", async () => {
|
||||
const factory = new Factory();
|
||||
const store = createOneTalkContactProfileStore(factory, () => 100);
|
||||
const first = profile("v1-url", "https://cdn.example.test/avatar/v1.jpg");
|
||||
const cleared = profile("v2-null", null);
|
||||
const restored = profile("v3-url", "https://cdn.example.test/avatar/v3.jpg");
|
||||
|
||||
await store.putPendingProfile("account-1", first);
|
||||
await store.putPendingProfile("account-1", cleared);
|
||||
await store.putPendingProfile("account-1", restored);
|
||||
|
||||
const record = await store.getProfile("account-1", restored.aliId);
|
||||
assert.equal(record.pending.profile.avatarUrl, restored.avatarUrl);
|
||||
assert.equal(record.pending.fingerprint, "v3-url");
|
||||
|
||||
await store.putPendingProfile("account-1", cleared);
|
||||
const clearedRecord = await store.getProfile("account-1", restored.aliId);
|
||||
assert.equal(clearedRecord.pending.profile.avatarUrl, null);
|
||||
assert.equal(clearedRecord.pending.fingerprint, "v2-null");
|
||||
|
||||
await store.putPendingProfile(
|
||||
"account-2",
|
||||
profile("account-2-avatar", "https://cdn.example.test/avatar/account-2.jpg"),
|
||||
);
|
||||
assert.equal(
|
||||
(await store.getProfile("account-1", restored.aliId)).pending.profile.avatarUrl,
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
(await store.getProfile("account-2", restored.aliId)).pending.profile.avatarUrl,
|
||||
"https://cdn.example.test/avatar/account-2.jpg",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -45,6 +45,7 @@ const profile = {
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.test/avatar/customer-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "v1-profile",
|
||||
observationStatus: "confirmed",
|
||||
@@ -292,6 +293,23 @@ test("routes a Port command through MAIN and returns its request correlation", a
|
||||
assert.deepEqual(port.posted, [result]);
|
||||
});
|
||||
|
||||
test("does not publish a command result after pagehide cleanup", async () => {
|
||||
const pageWindow = new FakePageWindow();
|
||||
let release;
|
||||
const pending = new Promise((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
installOneTalkMainPageBridge(pageWindow, () => pending);
|
||||
pageWindow.posted.length = 0;
|
||||
const command = createOneTalkPageCommandMessage("request-cleanup", { action: "send" });
|
||||
pageWindow.dispatchMessage(command);
|
||||
pageWindow.dispatchPageHide();
|
||||
release({ accepted: true });
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
assert.deepEqual(pageWindow.posted, []);
|
||||
});
|
||||
|
||||
test("drops messages after Port disconnect without creating a local queue", () => {
|
||||
const pageWindow = new FakePageWindow();
|
||||
const port = new FakePort();
|
||||
|
||||
@@ -40,6 +40,7 @@ const profile = {
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.test/avatar/customer-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "v1-profile",
|
||||
observationStatus: "confirmed",
|
||||
@@ -303,6 +304,58 @@ 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 () => {
|
||||
const persisted = [];
|
||||
const handled = [];
|
||||
const runtime = createOneTalkServiceWorkerRuntime({
|
||||
persistPageProfileObservation: async (message, _sender, channelAccountId) => {
|
||||
persisted.push([channelAccountId, message.profiles[0].avatarUrl]);
|
||||
},
|
||||
onPageProfileMessage: (message) => handled.push(message.channelAccountId),
|
||||
onPageMessage: () => undefined,
|
||||
});
|
||||
const port = new FakePort(pageSender(36));
|
||||
connectPage(runtime, port, "account-a", undefined);
|
||||
port.dispatchMessage(createOneTalkPageProfileObservedMessage([profile], "account-a"));
|
||||
port.dispatchMessage(createOneTalkPageHelloMessage("account-b", undefined));
|
||||
port.dispatchMessage(createOneTalkPageProfileObservedMessage([profile], "account-a"));
|
||||
port.dispatchMessage(
|
||||
createOneTalkPageProfileObservedMessage(
|
||||
[{ ...profile, avatarUrl: null, profileFingerprint: "v2-null" }],
|
||||
"account-b",
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(persisted, [
|
||||
["account-a", profile.avatarUrl],
|
||||
["account-b", null],
|
||||
]);
|
||||
assert.deepEqual(handled, ["account-a", "account-b"]);
|
||||
});
|
||||
|
||||
test("profile persistence failure does not block the independent message observer", async () => {
|
||||
const events = [];
|
||||
const runtime = createOneTalkServiceWorkerRuntime({
|
||||
persistPageProfileObservation: () => Promise.reject(new Error("profile IDB aborted")),
|
||||
onPageProfileMessage: () => events.push("profile"),
|
||||
persistPageObservation: async () => {
|
||||
events.push("message-persist");
|
||||
},
|
||||
onPageMessage: () => events.push("message"),
|
||||
onError: (error) => events.push(error.message),
|
||||
});
|
||||
const port = new FakePort(pageSender(37));
|
||||
connectPage(runtime, port, "account-1", "conversation-1");
|
||||
port.dispatchMessage(createOneTalkPageProfileObservedMessage([profile], "account-1"));
|
||||
port.dispatchMessage(createOneTalkPageObservedMessage([observedMessage]));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.equal(events.includes("profile"), false);
|
||||
assert.ok(events.includes("profile IDB aborted"));
|
||||
assert.ok(events.indexOf("message-persist") < events.indexOf("message"));
|
||||
});
|
||||
|
||||
test("diagnoses page routing outcomes without page or account identifiers", async () => {
|
||||
const diagnostics = [];
|
||||
const runtime = createOneTalkServiceWorkerRuntime({
|
||||
|
||||
@@ -6,10 +6,16 @@
|
||||
"scripts": {
|
||||
"dev": "node --watch --experimental-strip-types src/entry.ts",
|
||||
"start": "node --experimental-strip-types src/entry.ts",
|
||||
"prebuild": "pnpm --filter @trade-message-center/onetalk-contract build",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"pretypecheck": "pnpm --filter @trade-message-center/onetalk-contract build",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json",
|
||||
"pretest": "pnpm --filter @trade-message-center/onetalk-contract build",
|
||||
"test": "node --experimental-strip-types --test test/*.test.ts && pnpm run build && node --test dist/test/*.test.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@trade-message-center/onetalk-contract": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.3"
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// 校验并接收 Bright 的本地 profile receipt
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
|
||||
import {
|
||||
isOneTalkAvatarUrl,
|
||||
isOneTalkContactProfile,
|
||||
ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE,
|
||||
type OneTalkContactProfile,
|
||||
} from "@trade-message-center/onetalk-contract";
|
||||
|
||||
import type { MindMockAuthorization } from "../model.ts";
|
||||
import { hasExactKeys, isJsonRequest, isNonEmptyString, isRecord, readBody } from "../request.ts";
|
||||
import { writeNoContentResponse, writeRejection } from "../response.ts";
|
||||
import type { MindContactProfileStore } from "./store.ts";
|
||||
|
||||
export const MIND_CONTACT_PROFILE_PATH = "/internal/bright/onetalk/contact-profiles";
|
||||
|
||||
type ContactProfileReceipt = {
|
||||
channelAccountId: string;
|
||||
binding: string;
|
||||
profiles: OneTalkContactProfile[];
|
||||
};
|
||||
|
||||
const isApprovedProfile = (value: unknown): value is OneTalkContactProfile => {
|
||||
if (!isOneTalkContactProfile(value)) return false;
|
||||
return value.avatarUrl === null || isOneTalkAvatarUrl(value.avatarUrl);
|
||||
};
|
||||
|
||||
const readContactProfileReceipt = (body: unknown): ContactProfileReceipt | null => {
|
||||
if (
|
||||
!isRecord(body) ||
|
||||
!hasExactKeys(body, ["channelAccountId", "binding", "profiles"]) ||
|
||||
!isNonEmptyString(body.channelAccountId) ||
|
||||
!isNonEmptyString(body.binding) ||
|
||||
!Array.isArray(body.profiles) ||
|
||||
body.profiles.length === 0 ||
|
||||
body.profiles.length > ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE ||
|
||||
!body.profiles.every(isApprovedProfile)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
channelAccountId: body.channelAccountId,
|
||||
binding: body.binding,
|
||||
profiles: body.profiles,
|
||||
};
|
||||
};
|
||||
|
||||
/** 接收只含白名单 profile 的 Bright 本地 receipt,并覆盖已存头像值。 */
|
||||
export const handleContactProfileReceipt = async (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
authorization: MindMockAuthorization,
|
||||
store: MindContactProfileStore,
|
||||
): Promise<void> => {
|
||||
if (!isJsonRequest(request)) {
|
||||
writeRejection(response, 400, "invalid_request");
|
||||
return;
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await readBody(request);
|
||||
} catch {
|
||||
writeRejection(response, 400, "invalid_request");
|
||||
return;
|
||||
}
|
||||
|
||||
const input = readContactProfileReceipt(body);
|
||||
if (input === null) {
|
||||
writeRejection(response, 400, "invalid_request");
|
||||
return;
|
||||
}
|
||||
if (input.channelAccountId !== authorization.mindScope.channelAccountId) {
|
||||
writeRejection(response, 403, "scope_mismatch");
|
||||
return;
|
||||
}
|
||||
if (input.binding !== authorization.binding) {
|
||||
writeRejection(response, 403, "binding_revoked");
|
||||
return;
|
||||
}
|
||||
|
||||
store.upsert(input.channelAccountId, input.profiles);
|
||||
writeNoContentResponse(response);
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// 保存 Mind mock 已批准的联系人资料快照
|
||||
|
||||
import type { OneTalkContactProfile } from "@trade-message-center/onetalk-contract";
|
||||
|
||||
export type MindContactProfileStore = {
|
||||
upsert: (channelAccountId: string, profiles: readonly OneTalkContactProfile[]) => void;
|
||||
get: (channelAccountId: string, aliId: string) => OneTalkContactProfile | null;
|
||||
list: (channelAccountId: string) => OneTalkContactProfile[];
|
||||
};
|
||||
|
||||
const profileKey = (channelAccountId: string, aliId: string): string =>
|
||||
JSON.stringify([channelAccountId, aliId]);
|
||||
|
||||
const copyProfile = (profile: OneTalkContactProfile): OneTalkContactProfile => ({
|
||||
conversationId: profile.conversationId,
|
||||
aliId: profile.aliId,
|
||||
accountId: profile.accountId,
|
||||
loginId: profile.loginId,
|
||||
name: profile.name,
|
||||
companyName: profile.companyName,
|
||||
countryCode: profile.countryCode,
|
||||
currentTimeZone: profile.currentTimeZone,
|
||||
serviceType: profile.serviceType,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
observedAtMs: profile.observedAtMs,
|
||||
profileFingerprint: profile.profileFingerprint,
|
||||
observationStatus: profile.observationStatus,
|
||||
});
|
||||
|
||||
/** 创建按登录账号和客户 aliId 隔离的内存 profile store。 */
|
||||
export const createMindContactProfileStore = (): MindContactProfileStore => {
|
||||
const profiles = new Map<string, OneTalkContactProfile>();
|
||||
|
||||
return {
|
||||
upsert: (channelAccountId, nextProfiles) => {
|
||||
for (const profile of nextProfiles) {
|
||||
profiles.set(profileKey(channelAccountId, profile.aliId), copyProfile(profile));
|
||||
}
|
||||
},
|
||||
get: (channelAccountId, aliId) => {
|
||||
const profile = profiles.get(profileKey(channelAccountId, aliId));
|
||||
return profile === undefined ? null : copyProfile(profile);
|
||||
},
|
||||
list: (channelAccountId) => {
|
||||
const accountProfiles: OneTalkContactProfile[] = [];
|
||||
for (const [key, profile] of profiles) {
|
||||
if (key.startsWith(`${JSON.stringify([channelAccountId]).slice(0, -1)},`)) {
|
||||
accountProfiles.push(copyProfile(profile));
|
||||
}
|
||||
}
|
||||
return accountProfiles;
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -6,11 +6,19 @@ import { createMindAuthorizationServer, parseMindMockConfig } from "./server.ts"
|
||||
|
||||
export {
|
||||
createMindAuthorizationServer,
|
||||
createMindContactProfileStore,
|
||||
MIND_CONTACT_PROFILE_PATH,
|
||||
parseMindMockConfig,
|
||||
MIND_BINDING_AUTHORIZATION_PATH,
|
||||
MIND_SESSION_AUTHORIZATION_PATH,
|
||||
} from "./server.ts";
|
||||
export type { MindMockAuthorization, MindMockConfig, MindMockPermission } from "./server.ts";
|
||||
export type {
|
||||
MindContactProfileStore,
|
||||
MindMockAuthorization,
|
||||
MindMockConfig,
|
||||
MindMockDependencies,
|
||||
MindMockPermission,
|
||||
} from "./server.ts";
|
||||
|
||||
const entryPath = process.argv[1];
|
||||
const isMainModule = entryPath !== undefined && import.meta.url === pathToFileURL(entryPath).href;
|
||||
|
||||
@@ -16,6 +16,11 @@ export const writePlainResponse = (
|
||||
response.end(body);
|
||||
};
|
||||
|
||||
export const writeNoContentResponse = (response: ServerResponse): void => {
|
||||
response.writeHead(204, { "content-length": "0" });
|
||||
response.end();
|
||||
};
|
||||
|
||||
export const writeJsonResponse = (
|
||||
response: ServerResponse,
|
||||
statusCode: number,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// 组装 Mind mock 的两个授权 HTTP endpoint
|
||||
// 组装 Mind mock 授权和 profile receipt endpoint
|
||||
|
||||
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
||||
|
||||
@@ -7,6 +7,14 @@ import {
|
||||
MIND_BINDING_AUTHORIZATION_PATH,
|
||||
} from "./authorization/binding.ts";
|
||||
import { parseMindMockConfig, type MindMockConfig } from "./config.ts";
|
||||
import {
|
||||
handleContactProfileReceipt,
|
||||
MIND_CONTACT_PROFILE_PATH,
|
||||
} from "./contact-profile/handler.ts";
|
||||
import {
|
||||
createMindContactProfileStore,
|
||||
type MindContactProfileStore,
|
||||
} from "./contact-profile/store.ts";
|
||||
import { readPath } from "./request.ts";
|
||||
import { writePlainResponse } from "./response.ts";
|
||||
import {
|
||||
@@ -14,8 +22,15 @@ import {
|
||||
MIND_SESSION_AUTHORIZATION_PATH,
|
||||
} from "./authorization/session.ts";
|
||||
|
||||
export { MIND_BINDING_AUTHORIZATION_PATH, MIND_SESSION_AUTHORIZATION_PATH, parseMindMockConfig };
|
||||
export {
|
||||
MIND_BINDING_AUTHORIZATION_PATH,
|
||||
MIND_CONTACT_PROFILE_PATH,
|
||||
MIND_SESSION_AUTHORIZATION_PATH,
|
||||
createMindContactProfileStore,
|
||||
parseMindMockConfig,
|
||||
};
|
||||
export type { MindMockConfig } from "./config.ts";
|
||||
export type { MindContactProfileStore } from "./contact-profile/store.ts";
|
||||
export type {
|
||||
MindMockAuthorization,
|
||||
MindMockBindingResult,
|
||||
@@ -23,10 +38,15 @@ export type {
|
||||
MindMockSessionResult,
|
||||
} from "./model.ts";
|
||||
|
||||
export type MindMockDependencies = {
|
||||
contactProfileStore?: MindContactProfileStore;
|
||||
};
|
||||
|
||||
const handleRequest = async (
|
||||
request: IncomingMessage,
|
||||
response: ServerResponse,
|
||||
config: MindMockConfig,
|
||||
profileStore: MindContactProfileStore,
|
||||
): Promise<void> => {
|
||||
const path = readPath(request);
|
||||
if (request.method !== "POST") {
|
||||
@@ -52,11 +72,20 @@ const handleRequest = async (
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path === MIND_CONTACT_PROFILE_PATH) {
|
||||
await handleContactProfileReceipt(request, response, config.authorization, profileStore);
|
||||
return;
|
||||
}
|
||||
writePlainResponse(response, 404, "Not Found");
|
||||
};
|
||||
|
||||
/** 创建只提供两个授权路径的无状态 Mind HTTP mock server。 */
|
||||
export const createMindAuthorizationServer = (config: MindMockConfig): Server =>
|
||||
createServer((request, response) => {
|
||||
void handleRequest(request, response, config);
|
||||
/** 创建本地 Mind HTTP mock,组合授权端点和 profile receipt。 */
|
||||
export const createMindAuthorizationServer = (
|
||||
config: MindMockConfig,
|
||||
dependencies: MindMockDependencies = {},
|
||||
): Server => {
|
||||
const profileStore = dependencies.contactProfileStore ?? createMindContactProfileStore();
|
||||
return createServer((request, response) => {
|
||||
void handleRequest(request, response, config, profileStore);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -7,11 +7,14 @@ import type { AddressInfo } from "node:net";
|
||||
|
||||
import {
|
||||
createMindAuthorizationServer,
|
||||
MIND_CONTACT_PROFILE_PATH,
|
||||
parseMindMockConfig,
|
||||
type MindMockDependencies,
|
||||
type MindMockConfig,
|
||||
} from "../src/server.ts";
|
||||
import { MIND_BINDING_AUTHORIZATION_PATH } from "../src/authorization/binding.ts";
|
||||
import { MIND_SESSION_AUTHORIZATION_PATH } from "../src/authorization/session.ts";
|
||||
import { createMindContactProfileStore } from "../src/contact-profile/store.ts";
|
||||
|
||||
const testConfig = (overrides: Record<string, string> = {}): MindMockConfig =>
|
||||
parseMindMockConfig({
|
||||
@@ -27,10 +30,27 @@ const testConfig = (overrides: Record<string, string> = {}): MindMockConfig =>
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const profile = {
|
||||
conversationId: "conversation-1",
|
||||
aliId: "ali-1",
|
||||
accountId: "account-1",
|
||||
loginId: "buyer-1",
|
||||
name: "Buyer One",
|
||||
companyName: "Buyer Company",
|
||||
countryCode: "CN",
|
||||
currentTimeZone: 8,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.com/avatar-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "profile-1",
|
||||
observationStatus: "confirmed" as const,
|
||||
};
|
||||
|
||||
const startServer = async (
|
||||
config: MindMockConfig,
|
||||
dependencies: MindMockDependencies = {},
|
||||
): Promise<{ url: string; close: () => Promise<void> }> => {
|
||||
const server = createMindAuthorizationServer(config);
|
||||
const server = createMindAuthorizationServer(config, dependencies);
|
||||
server.listen(config.port, config.host);
|
||||
await once(server, "listening");
|
||||
const address = server.address() as AddressInfo;
|
||||
@@ -155,6 +175,142 @@ test("serves session allow, cookie rejection, and wrong account over TCP", async
|
||||
assert.deepEqual(wrongAccount.body, { code: "scope_mismatch" });
|
||||
});
|
||||
|
||||
test("retains URL and clears null in the account-scoped profile store over TCP", async (context) => {
|
||||
const store = createMindContactProfileStore();
|
||||
const running = await startServer(testConfig(), { contactProfileStore: store });
|
||||
closeAfterTest(context, running.close);
|
||||
|
||||
const saved = await fetchResponse(
|
||||
`${running.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [profile],
|
||||
}),
|
||||
);
|
||||
assert.equal(saved.status, 204);
|
||||
assert.equal(store.get("account-1", "ali-1")?.avatarUrl, profile.avatarUrl);
|
||||
|
||||
const cleared = await fetchResponse(
|
||||
`${running.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [{ ...profile, avatarUrl: null, profileFingerprint: "profile-2" }],
|
||||
}),
|
||||
);
|
||||
assert.equal(cleared.status, 204);
|
||||
assert.equal(store.get("account-1", "ali-1")?.avatarUrl, null);
|
||||
|
||||
for (const avatarUrl of ["a", "", "javascript:alert(1)", "data:image/png;base64,abc"]) {
|
||||
const rejected = await fetchResponse(
|
||||
`${running.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [{ ...profile, avatarUrl, profileFingerprint: `invalid-${avatarUrl}` }],
|
||||
}),
|
||||
);
|
||||
assert.equal(rejected.status, 400);
|
||||
assert.deepEqual(rejected.body, { code: "invalid_request" });
|
||||
assert.equal(store.get("account-1", "ali-1")?.avatarUrl, null);
|
||||
}
|
||||
});
|
||||
|
||||
test("isolates profile receipts by account and binding over TCP", async (context) => {
|
||||
const store = createMindContactProfileStore();
|
||||
const accountOne = await startServer(testConfig(), { contactProfileStore: store });
|
||||
const accountTwo = await startServer(
|
||||
testConfig({ MIND_MOCK_CHANNEL_ACCOUNT_ID: "account-2" }),
|
||||
{ contactProfileStore: store },
|
||||
);
|
||||
context.after(async () => {
|
||||
await accountOne.close();
|
||||
await accountTwo.close();
|
||||
});
|
||||
|
||||
const accountOneProfile = await fetchResponse(
|
||||
`${accountOne.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [profile],
|
||||
}),
|
||||
);
|
||||
const accountTwoProfile = await fetchResponse(
|
||||
`${accountTwo.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-2",
|
||||
binding: "binding-1",
|
||||
profiles: [{ ...profile, avatarUrl: "https://cdn.example.com/avatar-2.jpg" }],
|
||||
}),
|
||||
);
|
||||
assert.equal(accountOneProfile.status, 204);
|
||||
assert.equal(accountTwoProfile.status, 204);
|
||||
assert.equal(store.get("account-1", "ali-1")?.avatarUrl, profile.avatarUrl);
|
||||
assert.equal(
|
||||
store.get("account-2", "ali-1")?.avatarUrl,
|
||||
"https://cdn.example.com/avatar-2.jpg",
|
||||
);
|
||||
|
||||
const wrongAccount = await fetchResponse(
|
||||
`${accountOne.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-2",
|
||||
binding: "binding-1",
|
||||
profiles: [{ ...profile, avatarUrl: "https://cdn.example.com/should-not-save.jpg" }],
|
||||
}),
|
||||
);
|
||||
assert.equal(wrongAccount.status, 403);
|
||||
assert.deepEqual(wrongAccount.body, { code: "scope_mismatch" });
|
||||
assert.equal(store.get("account-1", "ali-1")?.avatarUrl, profile.avatarUrl);
|
||||
|
||||
const wrongBinding = await fetchResponse(
|
||||
`${accountOne.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-2",
|
||||
profiles: [{ ...profile, avatarUrl: "https://cdn.example.com/should-not-save.jpg" }],
|
||||
}),
|
||||
);
|
||||
assert.equal(wrongBinding.status, 403);
|
||||
assert.deepEqual(wrongBinding.body, { code: "binding_revoked" });
|
||||
assert.equal(store.get("account-1", "ali-1")?.avatarUrl, profile.avatarUrl);
|
||||
});
|
||||
|
||||
test("rejects extra and sensitive profile fields without storing or echoing the body", async (context) => {
|
||||
const store = createMindContactProfileStore();
|
||||
const running = await startServer(testConfig(), { contactProfileStore: store });
|
||||
closeAfterTest(context, running.close);
|
||||
|
||||
for (const [field, value] of [
|
||||
["chatToken", "secret-chat-token"],
|
||||
["ctoken", "secret-ctoken"],
|
||||
["_tb_token_", "secret-tb-token"],
|
||||
["secReqToken", "secret-request-token"],
|
||||
["Cookie", "secret-cookie"],
|
||||
["aliIdEncrypt", "encrypted-ali-id"],
|
||||
["accountIdEncrypt", "encrypted-account-id"],
|
||||
["loginIdEncrypt", "encrypted-login-id"],
|
||||
["callback", "jsonp-callback-args"],
|
||||
["rawRow", { nested: "raw-conversation-row" }],
|
||||
["imageBinary", "base64-image-data"],
|
||||
] as const) {
|
||||
const rejected = await fetchResponse(
|
||||
`${running.url}${MIND_CONTACT_PROFILE_PATH}`,
|
||||
jsonOptions({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [{ ...profile, [field]: value }],
|
||||
}),
|
||||
);
|
||||
assert.equal(rejected.status, 400);
|
||||
assert.deepEqual(rejected.body, { code: "invalid_request" });
|
||||
assert.equal(rejected.text.includes(String(value)), false);
|
||||
}
|
||||
assert.equal(store.get("account-1", "ali-1"), null);
|
||||
});
|
||||
|
||||
test("rejects unknown routes and bodies outside the exact endpoint shapes", async (context) => {
|
||||
const running = await startServer(testConfig());
|
||||
closeAfterTest(context, running.close);
|
||||
@@ -176,6 +332,14 @@ test("rejects unknown routes and bodies outside the exact endpoint shapes", asyn
|
||||
assert.equal(unknownPath.status, 404);
|
||||
assert.equal(unknownPath.text, "Not Found");
|
||||
|
||||
const collectorPath = await fetchResponse(`${running.url}/collector/v1/sync-batches`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ channelAccountId: "account-1", profiles: [] }),
|
||||
});
|
||||
assert.equal(collectorPath.status, 404);
|
||||
assert.equal(collectorPath.text, "Not Found");
|
||||
|
||||
for (const body of [
|
||||
{ channelAccountId: "account-1", binding: "binding-1", deviceId: "device-1" },
|
||||
{ channelAccountId: "account-1" },
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ONETALK_SYNC_MODES,
|
||||
ONETALK_SYNC_RESULTS,
|
||||
isOneTalkMindScope,
|
||||
isOneTalkAvatarUrl,
|
||||
isOneTalkPluginScope,
|
||||
type OneTalkAnchor,
|
||||
type OneTalkContactProfile,
|
||||
@@ -138,6 +139,7 @@ const PROFILE_KEYS = [
|
||||
"countryCode",
|
||||
"currentTimeZone",
|
||||
"serviceType",
|
||||
"avatarUrl",
|
||||
"observedAtMs",
|
||||
"profileFingerprint",
|
||||
"observationStatus",
|
||||
@@ -182,6 +184,7 @@ export const isOneTalkContactProfile = (value: unknown): value is OneTalkContact
|
||||
(value.countryCode === null || typeof value.countryCode === "string") &&
|
||||
(value.currentTimeZone === null || isFiniteNumber(value.currentTimeZone)) &&
|
||||
(value.serviceType === null || typeof value.serviceType === "string") &&
|
||||
(value.avatarUrl === null || isOneTalkAvatarUrl(value.avatarUrl)) &&
|
||||
typeof value.observedAtMs === "number" &&
|
||||
Number.isSafeInteger(value.observedAtMs) &&
|
||||
value.observedAtMs >= 0 &&
|
||||
|
||||
@@ -241,6 +241,25 @@ export type OneTalkContactProfileObservationStatus =
|
||||
export const ONETALK_CONTACT_PROFILE_MAX_BATCH_SIZE = 100;
|
||||
export const ONETALK_CONTACT_PROFILE_MAX_FRAME_BYTES = 256 * 1024;
|
||||
|
||||
const ONETALK_AVATAR_URL_PROTOCOLS = ["http:", "https:"] as const;
|
||||
|
||||
/** 判断头像是否为不含空白的绝对 HTTP(S) URL。 */
|
||||
export const isOneTalkAvatarUrl = (value: unknown): value is string => {
|
||||
if (typeof value !== "string" || value.length === 0 || value.trim() !== value) {
|
||||
return false;
|
||||
}
|
||||
if (/\s/u.test(value)) return false;
|
||||
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return (
|
||||
ONETALK_AVATAR_URL_PROTOCOLS.includes(url.protocol as never) && url.hostname.length > 0
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/** OneTalk 页面已经加载的、允许跨边界的基础联系人资料。 */
|
||||
export type OneTalkContactProfile = {
|
||||
conversationId: string;
|
||||
@@ -252,6 +271,7 @@ export type OneTalkContactProfile = {
|
||||
countryCode: string | null;
|
||||
currentTimeZone: number | null;
|
||||
serviceType: string | null;
|
||||
avatarUrl: string | null;
|
||||
observedAtMs: number;
|
||||
profileFingerprint: string;
|
||||
observationStatus: OneTalkContactProfileObservationStatus;
|
||||
|
||||
@@ -9,11 +9,13 @@ import {
|
||||
ONETALK_PROTOCOL_VERSION,
|
||||
createMockAuthorizationReader,
|
||||
createOneTalkAnchorSnapshotFrame,
|
||||
createOneTalkContactProfileObservedFrame,
|
||||
createOneTalkMessageAckFrame,
|
||||
createOneTalkPluginStatusFrame,
|
||||
createOneTalkSyncStatusFrame,
|
||||
decodeMindAuthorizationResponse,
|
||||
decodeOneTalkFrame,
|
||||
isOneTalkAvatarUrl,
|
||||
type OneTalkMindScope,
|
||||
type MockAuthorizationRecord,
|
||||
type OneTalkPluginScope,
|
||||
@@ -170,6 +172,7 @@ const contactProfile = {
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.test/avatar/customer-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "v1-12345678",
|
||||
observationStatus: "confirmed" as const,
|
||||
@@ -190,6 +193,17 @@ test("strictly decodes profile frames and rejects sensitive or incomplete payloa
|
||||
});
|
||||
assert.equal(ack.type, "contact.profile.ack");
|
||||
|
||||
const explicitNull = decode({
|
||||
...frameBase,
|
||||
type: "contact.profile.observed",
|
||||
payload: { profiles: [{ ...contactProfile, avatarUrl: null }] },
|
||||
});
|
||||
assert.equal(explicitNull.type, "contact.profile.observed");
|
||||
assert.equal(explicitNull.payload.profiles[0].avatarUrl, null);
|
||||
|
||||
const constructed = createOneTalkContactProfileObservedFrame(frameBase, [contactProfile]);
|
||||
assert.equal(constructed.payload.profiles[0].avatarUrl, contactProfile.avatarUrl);
|
||||
|
||||
const sensitiveFields = [
|
||||
["chatToken", "secret-chat-token"],
|
||||
["aliIdEncrypt", "secret-ali-id"],
|
||||
@@ -220,6 +234,65 @@ test("strictly decodes profile frames and rejects sensitive or incomplete payloa
|
||||
assert.equal(JSON.stringify(topLevelResult).includes("secret"), false);
|
||||
}
|
||||
|
||||
const extraField = decodeOneTalkFrame({
|
||||
...frameBase,
|
||||
type: "contact.profile.observed",
|
||||
payload: { profiles: [{ ...contactProfile, unexpected: "not-allowed" }] },
|
||||
});
|
||||
assert.deepEqual(extraField, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
|
||||
|
||||
for (const avatarUrl of [
|
||||
"",
|
||||
" ",
|
||||
"a",
|
||||
"/avatar/customer-1.jpg",
|
||||
"//cdn.example.test/avatar/customer-1.jpg",
|
||||
"javascript:alert(1)",
|
||||
"data:image/png;base64,not-an-avatar-url",
|
||||
"blob:https://cdn.example.test/avatar/customer-1",
|
||||
"ftp://cdn.example.test/avatar/customer-1.jpg",
|
||||
"https://cdn.example.test/avatar/customer 1.jpg",
|
||||
42,
|
||||
{},
|
||||
] as const) {
|
||||
assert.equal(isOneTalkAvatarUrl(avatarUrl), false);
|
||||
const invalidAvatar = decodeOneTalkFrame({
|
||||
...frameBase,
|
||||
type: "contact.profile.observed",
|
||||
payload: { profiles: [{ ...contactProfile, avatarUrl }] },
|
||||
});
|
||||
assert.deepEqual(invalidAvatar, {
|
||||
ok: false,
|
||||
code: ONETALK_ERROR_CODES.invalidMessage,
|
||||
});
|
||||
}
|
||||
|
||||
for (const avatarUrl of [
|
||||
"http://cdn.example.test/avatar/customer-1.jpg",
|
||||
"https://cdn.example.test/avatar/customer-1.jpg?size=small",
|
||||
]) {
|
||||
assert.equal(isOneTalkAvatarUrl(avatarUrl), true);
|
||||
const validAvatar = decodeOneTalkFrame({
|
||||
...frameBase,
|
||||
type: "contact.profile.observed",
|
||||
payload: { profiles: [{ ...contactProfile, avatarUrl }] },
|
||||
});
|
||||
assert.equal(validAvatar.ok, true);
|
||||
}
|
||||
|
||||
const missingAvatar = decodeOneTalkFrame({
|
||||
...frameBase,
|
||||
type: "contact.profile.observed",
|
||||
payload: {
|
||||
profiles: [
|
||||
Object.fromEntries(
|
||||
Object.entries(contactProfile).filter(([key]) => key !== "avatarUrl"),
|
||||
),
|
||||
],
|
||||
},
|
||||
});
|
||||
assert.deepEqual(missingAvatar, { ok: false, code: ONETALK_ERROR_CODES.invalidMessage });
|
||||
|
||||
const missingIdentity = decodeOneTalkFrame({
|
||||
...frameBase,
|
||||
type: "contact.profile.observed",
|
||||
|
||||
@@ -33,6 +33,7 @@ type MindContactProfile = Pick<
|
||||
| "countryCode"
|
||||
| "currentTimeZone"
|
||||
| "serviceType"
|
||||
| "avatarUrl"
|
||||
| "observedAtMs"
|
||||
| "profileFingerprint"
|
||||
| "observationStatus"
|
||||
@@ -66,6 +67,7 @@ const profileForMind = (profile: OneTalkContactProfile): MindContactProfile => (
|
||||
countryCode: profile.countryCode,
|
||||
currentTimeZone: profile.currentTimeZone,
|
||||
serviceType: profile.serviceType,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
observedAtMs: profile.observedAtMs,
|
||||
profileFingerprint: profile.profileFingerprint,
|
||||
observationStatus: profile.observationStatus,
|
||||
|
||||
@@ -18,37 +18,40 @@ const profile = {
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.com/avatar/profile-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "v1-profile",
|
||||
observationStatus: "confirmed" as const,
|
||||
};
|
||||
|
||||
test("sends only the profile delivery body and ACKs any HTTP response class", async () => {
|
||||
test("preserves URL and null avatar values and ACKs any HTTP response class", async () => {
|
||||
for (const status of [100, 200, 204, 302, 400, 404, 500, 503]) {
|
||||
let url = "";
|
||||
let request: RequestInit | undefined;
|
||||
const delivery = createMindContactProfileDelivery({
|
||||
baseUrl: "https://mind.example.com",
|
||||
timeoutMs: 100,
|
||||
fetch: async (input, init) => {
|
||||
url = String(input);
|
||||
request = init;
|
||||
return { status } as Response;
|
||||
},
|
||||
});
|
||||
const result = await delivery({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [profile],
|
||||
});
|
||||
assert.equal(result.delivered, true);
|
||||
assert.equal(url, `https://mind.example.com${MIND_CONTACT_PROFILE_PATH}`);
|
||||
assert.deepEqual(JSON.parse(String(request?.body)), {
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [profile],
|
||||
});
|
||||
assert.deepEqual(request?.headers, { "content-type": "application/json" });
|
||||
for (const profileValue of [profile, { ...profile, avatarUrl: null }]) {
|
||||
let url = "";
|
||||
let request: RequestInit | undefined;
|
||||
const delivery = createMindContactProfileDelivery({
|
||||
baseUrl: "https://mind.example.com",
|
||||
timeoutMs: 100,
|
||||
fetch: async (input, init) => {
|
||||
url = String(input);
|
||||
request = init;
|
||||
return { status } as Response;
|
||||
},
|
||||
});
|
||||
const result = await delivery({
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [profileValue],
|
||||
});
|
||||
assert.equal(result.delivered, true);
|
||||
assert.equal(url, `https://mind.example.com${MIND_CONTACT_PROFILE_PATH}`);
|
||||
assert.deepEqual(JSON.parse(String(request?.body)), {
|
||||
channelAccountId: "account-1",
|
||||
binding: "binding-1",
|
||||
profiles: [profileValue],
|
||||
});
|
||||
assert.deepEqual(request?.headers, { "content-type": "application/json" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -134,7 +137,7 @@ test("returns no_response when the AbortSignal timeout fires", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("does not ACK a network failure and never reads a response body", async () => {
|
||||
test("does not ACK a network failure and never reads body, text, or json", async () => {
|
||||
const delivery = createMindContactProfileDelivery({
|
||||
baseUrl: "https://mind.example.com",
|
||||
timeoutMs: 1,
|
||||
@@ -152,12 +155,22 @@ test("does not ACK a network failure and never reads a response body", async ()
|
||||
);
|
||||
|
||||
let bodyRead = false;
|
||||
let textGetterRead = false;
|
||||
let jsonGetterRead = false;
|
||||
const response = {
|
||||
status: 503,
|
||||
get body() {
|
||||
bodyRead = true;
|
||||
return null;
|
||||
},
|
||||
get text() {
|
||||
textGetterRead = true;
|
||||
return async () => "secret-response";
|
||||
},
|
||||
get json() {
|
||||
jsonGetterRead = true;
|
||||
return async () => ({ secret: "response" });
|
||||
},
|
||||
} as unknown as Response;
|
||||
const responseDelivery = createMindContactProfileDelivery({
|
||||
baseUrl: "https://mind.example.com",
|
||||
@@ -175,4 +188,6 @@ test("does not ACK a network failure and never reads a response body", async ()
|
||||
true,
|
||||
);
|
||||
assert.equal(bodyRead, false);
|
||||
assert.equal(textGetterRead, false);
|
||||
assert.equal(jsonGetterRead, false);
|
||||
});
|
||||
|
||||
@@ -56,6 +56,7 @@ const profile: OneTalkContactProfile = {
|
||||
countryCode: "CN",
|
||||
currentTimeZone: -9,
|
||||
serviceType: "cgs",
|
||||
avatarUrl: "https://cdn.example.com/avatar/profile-1.jpg",
|
||||
observedAtMs: 1_700_000_000_000,
|
||||
profileFingerprint: "v1-profile",
|
||||
observationStatus: "confirmed" as const,
|
||||
@@ -217,10 +218,12 @@ const assertNoAckDuring = async (socket: WebSocket, milliseconds = 30): Promise<
|
||||
|
||||
test("uses canonical plugin authorization and ACKs any Mind HTTP response", async () => {
|
||||
const calls: OneTalkContactProfileDeliveryInput[] = [];
|
||||
const diagnostics: unknown[] = [];
|
||||
const app = createApp(config, {
|
||||
database: createDatabaseStub(),
|
||||
authorization: createMockAuthorizationReader([authorizationRecord]),
|
||||
oneTalkService: service,
|
||||
onOneTalkDiagnostic: (event) => diagnostics.push(event),
|
||||
contactProfileDelivery: async (input: OneTalkContactProfileDeliveryInput) => {
|
||||
calls.push(input);
|
||||
return { delivered: true, httpStatusClass: "5xx" };
|
||||
@@ -240,6 +243,64 @@ test("uses canonical plugin authorization and ACKs any Mind HTTP response", asyn
|
||||
binding: "binding-1",
|
||||
profiles: [profile],
|
||||
});
|
||||
const diagnosticText = JSON.stringify(diagnostics);
|
||||
assert.equal(
|
||||
diagnosticText.includes("https://cdn.example.com/avatar/profile-1.jpg"),
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await closeApp(app, socket);
|
||||
}
|
||||
});
|
||||
|
||||
test("delivers profiles without invoking the message service or database", async () => {
|
||||
const noProfileService: OneTalkService = {
|
||||
discoverConversation: async () => {
|
||||
throw new Error("profile must not discover conversations");
|
||||
},
|
||||
listAnchors: async () => [],
|
||||
listConversations: async () => {
|
||||
throw new Error("profile must not list conversations");
|
||||
},
|
||||
readConversation: async () => {
|
||||
throw new Error("profile must not read conversations");
|
||||
},
|
||||
readHistory: async () => {
|
||||
throw new Error("profile must not read history");
|
||||
},
|
||||
observeMessage: async () => {
|
||||
throw new Error("profile must not observe messages");
|
||||
},
|
||||
completeSync: async () => {
|
||||
throw new Error("profile must not complete sync");
|
||||
},
|
||||
};
|
||||
const database = {
|
||||
db: new Proxy(
|
||||
{},
|
||||
{
|
||||
get: () => {
|
||||
throw new Error("profile must not access database");
|
||||
},
|
||||
},
|
||||
) as DatabaseConnection["db"],
|
||||
close: async () => undefined,
|
||||
} satisfies DatabaseConnection;
|
||||
const app = createApp(config, {
|
||||
database,
|
||||
authorization: createMockAuthorizationReader([authorizationRecord]),
|
||||
oneTalkService: noProfileService,
|
||||
contactProfileDelivery: async () => ({
|
||||
delivered: true,
|
||||
httpStatusClass: "2xx" as const,
|
||||
}),
|
||||
});
|
||||
const socket = await openPlugin(app);
|
||||
try {
|
||||
await connect(socket);
|
||||
const ack = nextMessage(socket);
|
||||
socket.send(JSON.stringify(profileFrame()));
|
||||
assert.equal((await ack).type, "contact.profile.ack");
|
||||
} finally {
|
||||
await closeApp(app, socket);
|
||||
}
|
||||
|
||||
@@ -695,6 +695,20 @@ CSRF
|
||||
| 右侧 DOM | 包含公司、邮箱、注册时间、买家标签 |
|
||||
| 基础 SDK 返回邮箱/注册时间 | 当前未包含 |
|
||||
|
||||
## 10.1 当前仓库的本地 profile receipt(仅测试)
|
||||
|
||||
当前仓库的 `apps/mind-http-mock` 提供一个仅供本地验证的接口:
|
||||
|
||||
```text
|
||||
POST /internal/bright/onetalk/contact-profiles
|
||||
Body: { channelAccountId, binding, profiles[] }
|
||||
Response: 204 No Content
|
||||
```
|
||||
|
||||
它复用共享 OneTalk profile/头像 URL 校验,只按 `channelAccountId + aliId` 在注入或默认的内存 store 中保存白名单字段;有效头像 URL 会保留,显式 `avatarUrl: null` 会覆盖旧值。账号或 binding 不匹配、额外或敏感字段会被拒绝,未知的 `/collector/v1/sync-batches` 路径仍返回 404。
|
||||
|
||||
该 receipt 只证明本地 mock 的请求边界和 URL/null 存储语义,不代表真实 Mind 服务已接通。仓库仍没有 TradeMind Workbench、`/collector/v1/sync-batches`、`customers[].avatarUrl` 或 `tm-binding-avatar` 实现;真实 Mind/Workbench/collector/Chromium 联调保持 `external_unverified`。
|
||||
|
||||
## 11. 历史最小示例(不可直接用于当前任务)
|
||||
|
||||
本节代码展示当时探查到的内部详情刷新方式,故意保留为未来任务的证据。当前实现不得调用其中的 HTTP 详情方法,也不得让 chatToken 或加密 ID 离开 MAIN world。
|
||||
|
||||
Generated
+4
@@ -38,6 +38,10 @@ importers:
|
||||
version: 6.4.3(@types/node@22.20.1)(tsx@4.23.12)(yaml@2.9.0)
|
||||
|
||||
apps/mind-http-mock:
|
||||
dependencies:
|
||||
'@trade-message-center/onetalk-contract':
|
||||
specifier: workspace:*
|
||||
version: link:../onetalk-contract
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^22.10.2
|
||||
|
||||
Reference in New Issue
Block a user