docs: record OneTalk customer profile fetching

This commit is contained in:
YBF
2026-08-27 17:18:42 +08:00
parent 69c3e5f394
commit 2fda27cc42
+760
View File
@@ -0,0 +1,760 @@
# OneTalk 客户资料获取方法与参数
> 记录日期:2026-08-27
>
> 适用页面:`https://onetalk.alibaba.com/message/weblitePWA.htm`
>
> 本文记录一次对真实 Chromium 页面运行时的只读探查结果,重点是如何获取会话对应的客户姓名、公司、登录 ID、阿里 ID 等资料,以及如何继续刷新客户详情。页面和静态 bundle 版本可能变化,生产代码必须保留特征检测、超时和字段白名单。
## 1. 结论
当前 OneTalk 页面有三层资料入口:
1. **首选:`window.__conversationListData__`**
会话列表模块已经把客户资料放在这个全局对象中。对于已经加载到会话列表的联系人,不需要额外请求,就能取得 `aliId``loginId`、姓名、公司等字段。
2. **补刷新:`conversationServiceHttp.getConversationContactDetailList()`**
`window.IcbuIM.IMBaaSSDK` 中具体的 `IcbuConversationServiceImpl` 实例带有内部 HTTP 适配器。该适配器可以用页面已有的加密 ID 和 chat token 刷新联系人资料。
3. **完整客户详情:客户详情微应用自己的 `contactMemberInfo` 请求**
右侧“客户详情”面板中的邮箱、注册时间等字段不在当前基础联系人对象中。静态 bundle 显示客户详情微应用会调用 `/message/contact/detail/contactMemberInfo.htm`。这部分应优先通过页面微应用已有逻辑或已渲染 DOM 获取,不要在插件 Service Worker 中自行拼接 CRM 请求。
最简单、最稳定的基础资料获取路径是:
```text
window.__conversationListData__
→ 读取 contact
→ 白名单提取字段
→ 发送给 Mind
```
缺资料时再走:
```text
IcbuConversationServiceImpl.getInstance()
.conversationServiceHttp
.getConversationContactDetailList(...)
```
## 2. 本次运行时探查结果
### 2.1 页面和 SDK 根对象
页面上的根对象存在:
```js
window.IcbuIM;
window.IcbuIM.IMBaaSSDK;
```
`window.IcbuIM.IMBaaSSDK.default` 当前只有:
```js
{
sdkVersion: "...";
}
```
客户资料相关对象在 `IMBaaSSDK` 顶层,而不在 `default` 中。已确认的相关入口包括:
```text
AuthService
ConversationService
MessageService
IcbuAuthServiceImpl
IcbuConversationServiceImpl
IcbuMessageServiceImpl
chatSdk
dataManager
baasDataManager
```
`chatSdk.getConversationService()` 返回的是公共会话服务,当前没有暴露客户详情方法。具体实现实例的原型包含:
```text
getConversationList
getConversationListByPagination
getConversationContactDetailList
listGroupMemberDetails
...
```
### 2.2 当前页面的客户资料全局对象
页面存在:
```js
window.__conversationListData__;
window.__conversationListMapFullData__;
window.__conversationListFullData__;
```
其中:
```js
window.__conversationListMapFullData__ === window.__conversationListData__;
```
会话列表 bundle 中的同步逻辑大致如下:
```js
window.__conversationListData__ = map;
window.__conversationListMapFullData__ = window.__conversationListData__;
window.EventBus.publish("im-conversation-list:syncData", map);
```
因此应当把 `__conversationListData__` 作为主读取入口,并订阅 `im-conversation-list:syncData` 获取后续更新。
本次页面上该对象有 2 个已加载会话。典型条目如下:
```js
window.__conversationListData__["2208314000798"] = {
cid: "2208314000798-2500002169502#11011@icbu",
aliId: 2208314000798,
accountId: 243340382,
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hangzhou Hago Enterprise Development Co., Ltd.",
complianceCountryCode: "CN",
contact: {
aliId: 2208314000798,
accountId: 243340382,
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hangzhou Hago Enterprise Development Co., Ltd.",
complianceCountryCode: "CN",
currentTimeZone: -9,
serviceType: "cgs",
},
};
```
实际条目还可能包含以下敏感或不应向 Mind 转发的字段:
```text
aliIdEncrypt
accountIdEncrypt
loginIdEncrypt
chatToken
ext.custom
latestMessage
msgCache
```
不能把整个会话对象直接序列化后发送给 Mind。
## 3. 直接读取已有客户资料
推荐使用 `contact` 优先、条目自身字段兜底的方式。这样可以兼容不同版本把资料放在顶层或 `contact` 下的情况。
```js
const PROFILE_FIELDS = [
"aliId",
"accountId",
"loginId",
"name",
"companyName",
"complianceCountryCode",
"currentTimeZone",
"serviceType",
];
const readConversationProfiles = () => {
const source = window.__conversationListData__ || {};
return Object.values(source).map((row) => {
const contact = row.contact || row;
return {
conversationId: row.cid || null,
aliId: contact.aliId ?? row.aliId ?? null,
accountId: contact.accountId ?? row.accountId ?? null,
loginId: contact.loginId ?? row.loginId ?? null,
name: contact.name ?? row.name ?? null,
companyName: contact.companyName ?? row.companyName ?? null,
countryCode: contact.complianceCountryCode ?? row.complianceCountryCode ?? null,
currentTimeZone: contact.currentTimeZone ?? row.currentTimeZone ?? null,
serviceType: contact.serviceType ?? row.serviceType ?? null,
};
});
};
```
`PROFILE_FIELDS` 是文档化的输出白名单;生产实现应显式构造对象,而不是使用:
```js
JSON.stringify(row);
```
### 3.1 订阅会话资料更新
会话列表模块会通过页面 `EventBus` 发布全量 map
```js
const consume = (map) => {
for (const row of Object.values(map || {})) {
const contact = row.contact || row;
const profile = {
conversationId: row.cid || null,
aliId: contact.aliId ?? row.aliId ?? null,
accountId: contact.accountId ?? row.accountId ?? null,
loginId: contact.loginId ?? row.loginId ?? null,
name: contact.name ?? row.name ?? null,
companyName: contact.companyName ?? row.companyName ?? null,
countryCode: contact.complianceCountryCode ?? row.complianceCountryCode ?? null,
};
// 只发送 profile,不发送原始 row。
publishProfileToMind(profile);
}
};
consume(window.__conversationListData__ || {});
const unsubscribe = window.EventBus?.on?.("im-conversation-list:syncData", consume);
```
`EventBus.on` 返回取消订阅函数。页面重新初始化或扩展卸载时应执行 `unsubscribe?.()`
注意:`__conversationListData__` 只代表页面当前已经同步到会话列表的数据,不代表所有历史消息或所有曾经存在的联系人。做全量回填时,仍然需要配合会话分页结果,对缺资料会话逐个补刷新。
## 4. SDK 刷新入口
### 4.1 错误入口:外层方法当前是空 Promise
下面这个路径在当前页面版本中存在,但实现为空:
```js
window.IcbuIM.IMBaaSSDK.IcbuConversationServiceImpl.getInstance().getConversationContactDetailList;
```
运行时函数体表现为:
```js
function (e) {
return new Promise(function (resolve, reject) {});
}
```
调用它不会发出网络请求,也不会完成 Promise。
`ConversationService.getInstance().getConversationContactDetailList()` 当前同样是空实现。不要把这两个外层方法作为生产入口。
### 4.2 已验证可用的入口:内部 HTTP 适配器
真正可用的实现位于具体实例的 `conversationServiceHttp`
```js
const sdk = window.IcbuIM?.IMBaaSSDK;
const service = sdk?.IcbuConversationServiceImpl?.getInstance?.();
const http = service?.conversationServiceHttp;
const [contact] = await http.getConversationContactDetailList([
{
aliId: row.aliId,
encryptAliId: row.aliIdEncrypt,
encryptAccountId: row.accountIdEncrypt,
chatToken: row.chatToken,
},
]);
```
本次使用当前页面的 Heena Liu 会话执行了该调用:
```text
请求:POST /message/listRecentConversationContactDetail.htm
HTTP 状态:200
返回联系人数量:1
```
返回资料中确认包含:
```text
serviceType
loginId
kHTAccessToken
companyName
complianceCountryCode
aliId
currentTimeZone
fullPortrait
accountId
name
productRelation
block
loginIdEncrypt
```
返回结果至少可以取得:
```js
{
aliId,
accountId,
loginId,
name,
companyName,
complianceCountryCode,
currentTimeZone,
serviceType,
block,
}
```
### 4.3 刷新入参来源
`row` 可以从 `window.__conversationListData__` 获得:
```js
const row = window.__conversationListData__[String(aliId)];
```
当前会话条目的顶层和 `contact` 对象都可以看到这些参数键:
| 参数 | 作用 | 是否可跨边界发送 |
| ------------------ | ------------------------ | ----------------------------- |
| `aliId` | 联系人的 OneTalk 阿里 ID | 可以,按白名单发送 |
| `encryptAliId` | `aliId` 的页面请求加密值 | 不可以,仅在页面内部使用 |
| `encryptAccountId` | 账号 ID 的页面请求加密值 | 不可以,仅在页面内部使用 |
| `chatToken` | 当前页面请求令牌 | 不可以,不能记录或发送给 Mind |
内部实现读取参数的规则是:
```js
if (item.encryptAliId) {
contactEncryptAliIdList.push(item.encryptAliId);
chatTokens.push(item.chatToken || "");
} else if (item.encryptAccountId) {
contactEncryptAccountIdList.push(item.encryptAccountId);
chatTokens.push(item.chatToken || "");
}
```
因此:
- 优先传 `encryptAliId`
- 没有 `encryptAliId` 时传 `encryptAccountId`
- `chatToken` 只在 OneTalk 页面上下文内部使用;
- 不要把加密字段和 token 放到插件 WebSocket、Mind 请求或日志中。
### 4.4 内部请求形状
`conversationServiceHttp.getConversationContactDetailList(items)` 内部会把输入转换为:
```js
{
contactEncryptAliIdList: ["..."],
contactEncryptAccountIdList: [],
chatTokens: "..."
}
```
实际接口路径为:
```text
POST https://onetalk.alibaba.com/message/listRecentConversationContactDetail.htm
```
生产代码不应在 Service Worker 或服务端手工调用这个 URL。正确做法是让 MAIN world 页面适配器调用页面已有服务,再返回已清洗的结果。
### 4.5 建议的安全包装
```js
const CONTACT_OUTPUT_FIELDS = [
"aliId",
"accountId",
"loginId",
"name",
"companyName",
"complianceCountryCode",
"currentTimeZone",
"serviceType",
"block",
];
const pickContactProfile = (value, conversationId) => {
const profile = { conversationId };
for (const field of CONTACT_OUTPUT_FIELDS) {
if (value && Object.prototype.hasOwnProperty.call(value, field)) {
profile[field] = value[field];
}
}
return profile;
};
const refreshContactProfile = async (row) => {
const sdk = window.IcbuIM?.IMBaaSSDK;
const service = sdk?.IcbuConversationServiceImpl?.getInstance?.();
const http = service?.conversationServiceHttp;
if (!http || typeof http.getConversationContactDetailList !== "function") {
throw new Error("onetalk_contact_detail_service_unavailable");
}
const request = {
aliId: row.aliId,
encryptAliId: row.aliIdEncrypt,
encryptAccountId: row.accountIdEncrypt,
chatToken: row.chatToken,
};
const list = await http.getConversationContactDetailList([request]);
const contact = Array.isArray(list) ? list[0] : null;
return pickContactProfile(contact, row.cid || null);
};
```
实际实现还应增加超时、页面身份检查和结果状态,例如:
```text
confirmed 已拿到资料且 ID 范围匹配
partial 只拿到姓名/公司等部分字段
not_found 页面返回空联系人
unavailable SDK 或页面服务不存在
timeout 页面请求未完成
identity_mismatch 返回资料和目标联系人不匹配
```
## 5. 返回资料与右侧完整详情的边界
### 5.1 基础联系人接口不包含的字段
本次 `listRecentConversationContactDetail.htm` 返回结果没有看到:
```text
email
registerTime
registrationTime
buyerTags
```
而右侧客户详情 DOM 中确实能看到:
```text
邮箱
注册时间
买家标签
```
因此不能假设 `getConversationContactDetailList` 会返回右侧卡片的全部字段。
### 5.2 右侧客户详情模块的请求
公开静态 bundle 中存在以下页面函数:
```js
getContactInfo;
```
其请求路径为:
```text
POST /message/contact/detail/contactMemberInfo.htm
```
页面代码传入的参数形状为:
```js
{
contactAccountIdEncrypt: contact.accountIdEncrypt,
chatToken: contact.chatToken,
}
```
页面逻辑随后从响应的 `data` 更新客户详情状态。这个请求属于 CRM 客户详情微应用,不是当前 `IMBaaSSDK` 顶层公开的简单方法。
建议优先级:
1. 让页面自己的客户详情微应用加载数据,再从稳定的页面状态或已渲染 DOM 读取;
2. 如果后续确认页面存在稳定的、页面自有的资料方法,再在 MAIN world 内调用;
3. 不在 Service Worker 中保存或重放 `chatToken`
4. 不把邮箱、注册时间等字段误认为消息字段。
### 5.3 DOM 兜底定位
本次页面看到的可用 DOM 特征包括:
```css
.alicrm-buyerLoginId-text
.name-text
.base-information-form-item
.base-information-form-item-label
.base-information-form-item-content .content
```
例如:
```html
<div class="alicrm-buyerLoginId-text">ID: <span>hzhago</span></div>
<div class="base-information-form-item">
<div class="base-information-form-item-label" title="公司名称">公司名称</div>
<div class="base-information-form-item-content">
<span class="content">Hangzhou Hago Enterprise Development Co., Ltd.</span>
</div>
</div>
```
DOM 读取必须以标签和精确容器为锚点,不能按客户显示名、列表序号或模糊文本点击。DOM 只作为完整详情补充,不应替代基础联系人全局数据。
## 6. ID 映射规则
### 6.1 当前页面观察到的 ID
当前 Heena Liu 会话中同时出现了:
```text
aliId = 2208314000798
loginId = hzhago
accountId = 243340382
```
右侧卡片显示的 `ID: hzhago``loginId`,不是 `aliId`
消息对象中的 `sender.targetId` 当前观察到更接近 `aliId`,例如:
```text
sender.targetId = 2208314000798
```
所以不能只保存一个“ID”字段。建议 Mind 侧至少保存:
```text
channelAccountId
aliId
loginId
name
companyName
```
资料映射主键建议为:
```text
channelAccountId + aliId
```
`loginId` 作为可展示和辅助匹配的别名保存。
### 6.2 不要把 `accountId` 误当成联系人 ID
当前联系人对象中的 `accountId` 与页面 active account 相关,不能仅凭名称判断它是买家的独立联系人 ID。生产实现应保留原始字段并在多个样本上验证:
```text
消息 sender.targetId
会话对象 aliId
资料对象 aliId
资料对象 loginId
页面 ID 文本
```
只有确认字段对应关系后,才能建立自动映射。
### 6.3 单聊和群聊
- 单聊:通常可以用会话对象的 `contact` 直接得到一个联系人。
- 群聊:会话标题不是联系人资料;需要使用成员列表服务逐个得到成员,再分别匹配 `aliId/loginId`
- 当前 `getConversationContactDetailList` 可以批量传多个联系人对象,但每个联系人仍必须带自己的加密 ID和页面 token。
## 7. 向 Mind 发送的建议数据契约
客户资料应作为独立资料事件发送,不要附加到消息正文,也不要把原始会话对象当作 payload:
```js
{
type: "contact.profile.observed",
source: "onetalk.page",
channelAccountId: "243340382",
conversationId: "2208314000798-2500002169502#11011@icbu",
aliId: "2208314000798",
loginId: "hzhago",
name: "Heena Liu",
companyName: "Hangzhou Hago Enterprise Development Co., Ltd.",
countryCode: "CN",
observedAt: "2026-08-27T...Z",
sourceFields: [
"conversation.contact",
"conversationListData",
],
}
```
建议增加:
```text
observationStatus: confirmed | partial | unavailable | mismatch
profileFingerprint
lastObservedAt
manualLock / manuallyVerified
```
Mind 侧如果已有联系人表,优先复用。没有合适结构时,可以增加一个资料观察/映射表,而不是把这些字段写入消息表。
## 8. 推荐插件实现结构
建议新增独立的页面资料观察模块,例如:
```text
apps/chrome-extension/src/onetalk/main-page/contact-observer/
```
模块职责:
1. 读取 `window.__conversationListData__` 初始快照;
2. 订阅 `im-conversation-list:syncData`
3. 对资料缺失的会话调用 `conversationServiceHttp.getConversationContactDetailList`
4. 对邮箱、注册时间等完整详情使用页面微应用状态或 DOM 补充;
5. 对返回对象执行字段白名单清洗;
6. 通过已有 page bridge / Service Worker 发送给 Mind
7.`channelAccountId + aliId` 去重;
8. 对每个联系人记录成功、部分成功、超时和 ID 不匹配状态。
推荐采集策略:
```text
实时会话列表更新 → 立即采集基础资料
用户打开会话 → 按需刷新完整资料
显式“回填客户资料” → 对缺资料会话批量补采集
```
不要对每一条消息重复请求客户资料;应按联系人去重。
## 9. 错误处理与可观测性
### 9.1 必须区分的错误
```text
onetalk_contact_profile_global_missing
onetalk_contact_detail_service_unavailable
onetalk_contact_detail_input_missing
onetalk_contact_detail_timeout
onetalk_contact_detail_empty
onetalk_contact_profile_identity_mismatch
onetalk_contact_profile_partial
```
### 9.2 诊断字段
日志可以记录:
```text
requestId
channelAccountId
conversationId
aliId(必要时)
结果状态
返回字段名集合
耗时
时间戳
```
日志禁止记录:
```text
chatToken
aliIdEncrypt
accountIdEncrypt
loginIdEncrypt
kHTAccessToken
Cookie
CSRF
完整原始响应
```
### 9.3 页面离线
本次第一次调用外层空 Promise 时没有任何请求;改用内部 HTTP 适配器后能收到 200 响应。若页面自身网络状态为断开,内部方法可能长时间不完成,因此必须在插件侧设置明确超时,并把结果标记为 `timeout`,不能无限等待。
## 10. 验证记录
本次只读探查验证了以下事实:
| 检查项 | 结果 |
| --------------------------------------------------------------- | ---------------------------------------------------------------- |
| 远程 Chromium 9222 | 可连接 |
| OneTalk 页面 | 已找到 |
| `window.IcbuIM.IMBaaSSDK` | 存在 |
| `IMBaaSSDK.default` | 只有 `sdkVersion` |
| `window.__conversationListData__` | 存在,当前有 2 个会话条目 |
| 基础资料 | 已包含 `aliId/loginId/name/companyName` |
| 外层 `getConversationContactDetailList` | 空 Promise,不可用 |
| 内部 `conversationServiceHttp.getConversationContactDetailList` | 可用 |
| 客户详情请求 | `POST /message/listRecentConversationContactDetail.htm` 返回 200 |
| 右侧 DOM | 包含公司、邮箱、注册时间、买家标签 |
| 基础 SDK 返回邮箱/注册时间 | 当前未包含 |
## 11. 最小可用实现示例
下面是只返回安全字段的最小示例。它应当在 OneTalk 页面 MAIN world 中执行:
```js
async function collectOneTalkContact(aliId) {
const row = window.__conversationListData__?.[String(aliId)];
if (!row) {
return { status: "not_found", aliId: String(aliId) };
}
const contact = row.contact || row;
const sdk = window.IcbuIM?.IMBaaSSDK;
const service = sdk?.IcbuConversationServiceImpl?.getInstance?.();
const http = service?.conversationServiceHttp;
let refreshed = null;
if (
http &&
typeof http.getConversationContactDetailList === "function" &&
(row.aliIdEncrypt || row.accountIdEncrypt) &&
row.chatToken
) {
const [detail] = await http.getConversationContactDetailList([
{
aliId: contact.aliId || row.aliId,
encryptAliId: contact.aliIdEncrypt || row.aliIdEncrypt,
encryptAccountId: contact.accountIdEncrypt || row.accountIdEncrypt,
chatToken: contact.chatToken || row.chatToken,
},
]);
refreshed = detail || null;
}
const source = refreshed || contact;
return {
status: refreshed ? "confirmed" : "partial",
conversationId: row.cid || null,
aliId: source.aliId ?? contact.aliId ?? row.aliId ?? null,
accountId: source.accountId ?? contact.accountId ?? row.accountId ?? null,
loginId: source.loginId ?? contact.loginId ?? row.loginId ?? null,
name: source.name ?? contact.name ?? row.name ?? null,
companyName: source.companyName ?? contact.companyName ?? row.companyName ?? null,
countryCode:
source.complianceCountryCode ??
contact.complianceCountryCode ??
row.complianceCountryCode ??
null,
serviceType: source.serviceType ?? contact.serviceType ?? row.serviceType ?? null,
};
}
```
该示例故意没有返回:
```text
chatToken
aliIdEncrypt
accountIdEncrypt
loginIdEncrypt
kHTAccessToken
fullPortrait
productRelation
完整原始响应
```
如果需要邮箱、注册时间或买家标签,应在此基础上另加一个“完整详情观察”步骤,而不是扩大基础联系人接口的敏感字段转发范围。