fix: use logged-in OneTalk account for channel scope

This commit is contained in:
YBF
2026-08-31 17:23:37 +08:00
parent 33c578f83d
commit b40eaa471d
19 changed files with 153 additions and 62 deletions
@@ -52,7 +52,7 @@ isAccountId(value: unknown): value is OneTalkAccountId;
## 3. Contracts
- SDK 路径固定为 `window.IcbuIM.IMBaaSSDK.default`;不得在 `window` 顶层猜测 getter。
- 调用时 URL 的 `activeAccountId` 只用于检测同步期间页面账号是否切换,不用于过滤会话;会话分页返回的每一条有效记录都必须同步。
- 调用时页面运行时登录人账号只用于检测同步期间页面账号是否切换;URL 的 `activeAccountId` 仅是当前对话账号,不用于账号识别或过滤会话;会话分页返回的每一条有效记录都必须同步。
- 每条会话只保留 `cid``accountId``accountIdEncrypt``aliId` 和可选的 `aliIdEncrypt`;真实页面的 `chatToken``contact``owner` 等字段不进入内部契约。
- `isAccountId` 只定义 OneTalk 页面接受的账号 ID 形状,不决定某条会话是否属于当前账号。
- `fail` 只执行 `throw new Error(message)`,不得拥有历史同步错误码、日志、重试或恢复策略。
@@ -80,11 +80,11 @@ isAccountId(value: unknown): value is OneTalkAccountId;
| 条件 | 行为 |
| --- | --- |
| URL 无有效 `activeAccountId` | `onetalk_history_invalid_active_account` |
| 页面无有效登录人账号(`currentUserAccountId` / `IcbuIM.UserUtil.currentUser.accountId` | `onetalk_history_invalid_active_account` |
| SDK/getter/no-read 方法缺失 | `onetalk_history_sdk_unavailable` |
| 会话页结构无效 | `onetalk_history_invalid_conversation_page` |
| 会话游标缺失或重复 | `onetalk_history_conversation_cursor_stalled` |
| 同步中 `activeAccountId` 改变 | `onetalk_history_active_account_changed` |
| 同步中登录人账号改变 | `onetalk_history_active_account_changed` |
| 历史页结构或字段无效 | 该会话标记 `onetalk_history_invalid_message_page`,继续其它会话 |
| 无新 messageId 或最老 sendTime 未前进 | `onetalk_history_message_cursor_stalled` |
| 单条会话 SDK 请求失败 | 该会话标记 `onetalk_history_message_request_failed`,继续其它会话 |
@@ -99,7 +99,7 @@ isAccountId(value: unknown): value is OneTalkAccountId;
## 6. Tests Required
- 断言会话调用依次使用 `cursor=0` 和上一页 `nextCursor`
- 断言不会按 `activeAccountId` 过滤会话,且每条会话都会获得独立结果。
- 断言不会按 URL `activeAccountId` 过滤会话,且每条会话都会获得独立结果。
- 断言只调用 `fetchMessagesWithoutUpdateToRead`,普通 `fetchMessages``updateMessageToRead` 调用次数为零。
- 断言 `conversationCode`、请求身份字段、`timeSlide` 和规范化 conversation 第二参数。
- 断言扁平数字/字符串 messageId 均可解析和去重,根级 sendTime 驱动下一页。
@@ -99,7 +99,7 @@ Chrome content script 入口不依赖 Service Worker 的 module 声明。MAIN
### Page identity
页面账号身份从 URL 的 \`activeAccountId\` 读取;当前 SPA 会话身份从页面当前状态读取:
页面账号身份从 OneTalk 运行时登录人字段 \`currentUserAccountId\`(或 \`IcbuIM.UserUtil.currentUser.accountId\`)读取;URL 的 \`activeAccountId\` 只是当前对话账号,不能作为 \`channelAccountId\`。当前 SPA 会话身份从页面当前状态读取:
- 唯一 selected 会话通过 hello 携带 \`conversationId\`
- 没有 selected 会话通过 \`conversationSelection: "none"\` 表达;
@@ -64,7 +64,7 @@ Bright WebSocket
### Page and account boundaries
- 页面账号从 activeAccountId 读取;SPA 当前会话来自页面自身的 selected 状态,不从 URL conversationId 猜测。
- 页面账号从 OneTalk 运行时登录人 `currentUserAccountId`(或 `IcbuIM.UserUtil.currentUser.accountId`)读取;URL `activeAccountId` 仅是当前对话账号。SPA 当前会话来自页面自身的 selected 状态,不从 URL conversationId 猜测。
- 页面 Port 必须按固定名称、origin、tab/frame 身份注册。
- 页面命令不得广播、跨账号回退或随机选择。
- onetalk.sync 使用精确的账号/会话页面路由。
+3 -3
View File
@@ -65,7 +65,7 @@ ONETALK_DEV_PERMISSIONS=read,send
变量规则:
- `ONETALK_DEV_CHANNEL_ACCOUNT_ID` 必须与 OneTalk 当前页面 URL 中的 `activeAccountId` 以及扩展 Popup 显示的 Active account ID 一致
- `ONETALK_DEV_CHANNEL_ACCOUNT_ID` 必须与 OneTalk 运行时登录人 `currentUserAccountId`(或 `IcbuIM.UserUtil.currentUser.accountId`以及扩展 Popup 配置值一致;URL 的 `activeAccountId` 是当前对话账号,不能作为 channel account
- `ONETALK_DEV_BINDING` 必须与 Popup 中输入的 binding 一致。binding 不要提交到仓库,也不要写入 URL 或日志。
- `ONETALK_DEV_PERMISSIONS` 只能使用 `read``send`,或用逗号组合为 `read,send`;历史读取和同步需要 `read`
- `ONETALK_DEV_AUTHORIZATION_VERSION` 是返回给客户端的授权版本字符串;授权视图发生切换时应更新它。
@@ -122,8 +122,8 @@ http://127.0.0.1:7878/harness
1. 执行 `pnpm dev`,确认生成 `apps/chrome-extension/dist/`
2. 打开 `chrome://extensions`,启用“开发者模式”,选择“加载已解压的扩展程序”,加载 `apps/chrome-extension/dist/`
3. 打开带有正确 `activeAccountId` OneTalk 页面 `https://onetalk.alibaba.com/`,点击扩展图标打开 Popup。
4. Popup 会从当前页面读取 Active account ID,并首次运行自动生成 Device ID;在 Binding 输入框填入 `ONETALK_DEV_BINDING` 对应的值后保存配置。
3. 打开 OneTalk 页面 `https://onetalk.alibaba.com/`,点击扩展图标打开 Popup;Popup 会从页面运行时读取登录人账号 ID(`currentUserAccountId`),不要填写 URL 的 `activeAccountId`
4. 在 Binding 输入框填入 `ONETALK_DEV_BINDING` 对应的值后保存配置;Popup 首次运行会自动生成 Device ID。URL 中的 `activeAccountId` 仅是当前对话账号
5. Bright WebSocket URL 仅展示当前构建使用的地址,不在 Popup 中手工填写。配置保存或清除后,Service Worker 会动态建立、重建或关闭 Bright 连接和同步引擎。
扩展配置保存在 `chrome.storage.local`。Popup 不配置 `mindUserId``workspaceId`;保存 binding 时留空表示保持已有 binding,输入新值才会替换它。清除配置不会重置自动生成的 Device ID。
+4 -4
View File
@@ -265,13 +265,13 @@
<section class="identity-panel" aria-label="当前 OneTalk 身份与配置">
<div class="identity-grid">
<div class="identity-row">
<span class="identity-label">Active account ID</span>
<output id="active-account-id" aria-live="polite">正在读取…</output>
<button id="copy-active-account" class="copy-button" type="button" disabled>
<span class="identity-label">登录人账号 ID</span>
<output id="channel-account-id" aria-live="polite">正在读取…</output>
<button id="copy-channel-account" class="copy-button" type="button" disabled>
复制
</button>
<span
id="active-account-copy-status"
id="channel-account-copy-status"
class="copy-status"
role="status"
aria-live="polite"
+59 -24
View File
@@ -6,12 +6,12 @@ import {
type OneTalkExtensionConfigView,
isOneTalkConfigStatusEvent,
} from "../src/onetalk/config.ts";
import { readActiveAccountIdFromUrl } from "../src/onetalk/main-page/page-context.ts";
import { BRIGHT_WEBSOCKET_URL, IS_ONE_TALK_DEVELOPMENT_MODE } from "../src/onetalk/build-config.ts";
declare const __TMC_BUILD_HASH__: string;
type ChromeTab = {
id?: number;
url?: string;
};
@@ -19,6 +19,13 @@ type PopupChromeApi = {
tabs: {
query: (queryInfo: { active: true; currentWindow: true }) => Promise<ChromeTab[]>;
};
scripting: {
executeScript: (options: {
target: { tabId: number };
world: "MAIN";
func: () => unknown;
}) => Promise<Array<{ result?: unknown }>>;
};
runtime: {
sendMessage: (message: OneTalkConfigMessage) => Promise<unknown>;
onMessage: { addListener: (listener: (message: unknown) => void) => void };
@@ -26,13 +33,13 @@ type PopupChromeApi = {
};
const chromeApi = (globalThis as unknown as { chrome?: PopupChromeApi }).chrome;
const activeAccountIdElement = document.querySelector<HTMLOutputElement>("#active-account-id");
const channelAccountIdElement = document.querySelector<HTMLOutputElement>("#channel-account-id");
const deviceIdElement = document.querySelector<HTMLOutputElement>("#device-id");
const buildHashElement = document.querySelector<HTMLOutputElement>("#build-hash");
const copyActiveAccountButton = document.querySelector<HTMLButtonElement>("#copy-active-account");
const copyChannelAccountButton = document.querySelector<HTMLButtonElement>("#copy-channel-account");
const copyDeviceIdButton = document.querySelector<HTMLButtonElement>("#copy-device-id");
const activeAccountCopyStatusElement = document.querySelector<HTMLElement>(
"#active-account-copy-status",
const channelAccountCopyStatusElement = document.querySelector<HTMLElement>(
"#channel-account-copy-status",
);
const deviceCopyStatusElement = document.querySelector<HTMLElement>("#device-copy-status");
const form = document.querySelector<HTMLFormElement>("#configuration-form");
@@ -45,12 +52,12 @@ const developerDetailsElement = document.querySelector<HTMLElement>("#developer-
if (
!chromeApi ||
!activeAccountIdElement ||
!channelAccountIdElement ||
!deviceIdElement ||
!buildHashElement ||
!copyActiveAccountButton ||
!copyChannelAccountButton ||
!copyDeviceIdButton ||
!activeAccountCopyStatusElement ||
!channelAccountCopyStatusElement ||
!deviceCopyStatusElement ||
!form ||
!brightWebSocketUrlOutput ||
@@ -63,12 +70,12 @@ if (
throw new Error("Popup elements are missing");
}
const activeAccountIdOutput = activeAccountIdElement;
const channelAccountIdOutput = channelAccountIdElement;
const deviceIdOutput = deviceIdElement;
const buildHashOutput = buildHashElement;
const copyActiveAccount = copyActiveAccountButton;
const copyChannelAccount = copyChannelAccountButton;
const copyDeviceId = copyDeviceIdButton;
const activeAccountCopyStatus = activeAccountCopyStatusElement;
const channelAccountCopyStatus = channelAccountCopyStatusElement;
const deviceCopyStatus = deviceCopyStatusElement;
const configurationForm = form;
const brightWebSocketUrl = brightWebSocketUrlOutput;
@@ -101,7 +108,7 @@ const errorLabels: Record<string, string> = {
storage_unavailable: "扩展存储暂不可用",
};
let activeAccountIdValue: string | null = null;
let channelAccountIdValue: string | null = null;
let deviceIdValue: string | null = null;
const isRecord = (value: unknown): value is Record<string, unknown> => {
@@ -129,11 +136,11 @@ const showError = (code: string | undefined): void => {
error.hidden = false;
};
const renderActiveAccountId = (value: string | null): void => {
activeAccountIdValue = value;
activeAccountIdOutput.textContent = value ?? "未检测到 activeAccountId";
copyActiveAccount.disabled = value === null;
activeAccountCopyStatus.textContent = "";
const renderChannelAccountId = (value: string | null): void => {
channelAccountIdValue = value;
channelAccountIdOutput.textContent = value ?? "未检测到登录人账号 ID";
copyChannelAccount.disabled = value === null;
channelAccountCopyStatus.textContent = "";
};
const renderDeviceId = (value: string): void => {
@@ -204,10 +211,38 @@ const sendConfigurationMessage = async (
const renderCurrentTab = async (): Promise<void> => {
try {
const [tab] = await chromeApi.tabs.query({ active: true, currentWindow: true });
const accountId = tab?.url ? readActiveAccountIdFromUrl(tab.url) : null;
renderActiveAccountId(accountId);
if (!tab?.id || !tab.url || new URL(tab.url).hostname !== "onetalk.alibaba.com") {
renderChannelAccountId(null);
return;
}
const [execution] = await chromeApi.scripting.executeScript({
target: { tabId: tab.id },
world: "MAIN",
func: () => {
const page = globalThis as unknown as {
currentUserAccountId?: unknown;
IcbuIM?: { UserUtil?: { currentUser?: { accountId?: unknown } } };
};
const direct = page.currentUserAccountId;
if (
(typeof direct === "string" && direct.trim()) ||
(typeof direct === "number" && Number.isFinite(direct))
) {
return String(direct).trim();
}
const nested = page.IcbuIM?.UserUtil?.currentUser?.accountId;
if (
(typeof nested === "string" && nested.trim()) ||
(typeof nested === "number" && Number.isFinite(nested))
) {
return String(nested).trim();
}
return null;
},
});
renderChannelAccountId(typeof execution?.result === "string" ? execution.result : null);
} catch {
renderActiveAccountId(null);
renderChannelAccountId(null);
}
};
@@ -222,13 +257,13 @@ const loadConfiguration = async (): Promise<void> => {
};
const saveConfiguration = async (): Promise<void> => {
if (!activeAccountIdValue) {
if (!channelAccountIdValue) {
showError("missing_channel_account_id");
return;
}
const rawBinding = binding.value.trim();
const config: Record<string, unknown> = {
channelAccountId: activeAccountIdValue,
channelAccountId: channelAccountIdValue,
...(rawBinding.length === 0 ? {} : { binding: rawBinding }),
};
try {
@@ -257,8 +292,8 @@ developerDetails.hidden = !IS_ONE_TALK_DEVELOPMENT_MODE;
chromeApi.runtime.onMessage.addListener((message) => {
if (isOneTalkConfigStatusEvent(message)) renderStatusEvent(message);
});
copyActiveAccount.addEventListener("click", () => {
void copyIdentityValue(activeAccountIdValue, copyActiveAccount, activeAccountCopyStatus);
copyChannelAccount.addEventListener("click", () => {
void copyIdentityValue(channelAccountIdValue, copyChannelAccount, channelAccountCopyStatus);
});
copyDeviceId.addEventListener("click", () => {
void copyIdentityValue(deviceIdValue, copyDeviceId, deviceCopyStatus);
+1 -1
View File
@@ -9,7 +9,7 @@
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"permissions": ["activeTab", "storage"],
"permissions": ["activeTab", "scripting", "storage"],
"background": {
"service_worker": "service-worker.js",
"type": "module"
@@ -1,12 +1,12 @@
// 编排 OneTalk 全会话只读历史同步
import { fail } from "../../../lib/error.ts";
import { readActiveAccountId } from "../page-context.ts";
import { readChannelAccountId } from "../page-context.ts";
import type { OneTalkPageWindow } from "../model.ts";
import {
allConversationHistoryErrorCodes,
isCursor,
normalizeActiveAccountId,
normalizeChannelAccountId,
normalizeConversation,
type AllConversationHistoryDependencies,
type AllConversationHistoryErrorCode,
@@ -63,8 +63,8 @@ const emitProgress = async (
await dependencies.onProgress?.(progress);
};
const assertActiveAccount = (pageWindow: OneTalkPageWindow, expected: string): void => {
if (normalizeActiveAccountId(readActiveAccountId(pageWindow)) !== expected) {
const assertChannelAccount = (pageWindow: OneTalkPageWindow, expected: string): void => {
if (normalizeChannelAccountId(readChannelAccountId(pageWindow)) !== expected) {
fail(allConversationHistoryErrorCodes.activeAccountChanged);
}
};
@@ -125,9 +125,9 @@ const syncConversation = async (
const anchorMessageId = dependencies.anchors?.[conversation.cid] ?? null;
const searchingForAnchor = requestedMode === "incremental" && anchorMessageId !== null;
while (true) {
assertActiveAccount(pageWindow, accountId);
assertChannelAccount(pageWindow, accountId);
const page = await fetchMessagesWithoutUpdateToRead(service, conversation, timeStamp);
assertActiveAccount(pageWindow, accountId);
assertChannelAccount(pageWindow, accountId);
pages += 1;
let oldestTime = Number.POSITIVE_INFINITY;
@@ -204,8 +204,8 @@ const run = async (
pageWindow: OneTalkPageWindow,
dependencies: ResolvedHistoryDependencies,
): Promise<AllConversationHistoryResult> => {
const accountId = normalizeActiveAccountId(readActiveAccountId(pageWindow));
const assertContext = (): void => assertActiveAccount(pageWindow, accountId);
const accountId = normalizeChannelAccountId(readChannelAccountId(pageWindow));
const assertContext = (): void => assertChannelAccount(pageWindow, accountId);
const sdk = pageSdk(pageWindow);
const allConversations = await getAllConversations(conversationService(sdk), assertContext);
const service = messageService(sdk);
@@ -105,7 +105,7 @@ export type AllConversationHistoryResult = {
export type CurrentConversationHistorySyncResult = AllConversationHistoryResult;
export const normalizeActiveAccountId = (value: string | null): string => {
export const normalizeChannelAccountId = (value: string | null): string => {
return value || fail(allConversationHistoryErrorCodes.invalidActiveAccount);
};
@@ -7,6 +7,8 @@ export type OneTalkPageWindow = {
document?: Pick<Document, "querySelectorAll" | "addEventListener">;
IcbuIM?: unknown;
__tradeMessageCenterOneTalk?: unknown;
/** OneTalk runtime's logged-in account identifier. */
currentUserAccountId?: unknown;
};
/** 判断值是否符合 OneTalk 账号标识形状。 */
@@ -16,10 +16,33 @@ export const readActiveAccountIdFromUrl = (href: string): string | null => {
}
};
/** 读取当前对话联系人账号;不得将其用作 channelAccountId。 */
export const readActiveAccountId = (pageWindow: OneTalkPageWindow): string | null => {
return readActiveAccountIdFromUrl(pageWindow.location.href);
};
const accountIdString = (value: unknown): string | null => {
if (typeof value === "string" && value.trim()) return value.trim();
if (typeof value === "number" && Number.isFinite(value)) return String(value);
return null;
};
/**
* Return the logged-in OneTalk account. `activeAccountId` identifies the
* selected contact/account and must never be used as channelAccountId.
*/
export const readChannelAccountId = (pageWindow: OneTalkPageWindow): string | null => {
const direct = accountIdString(pageWindow.currentUserAccountId);
if (direct) return direct;
const icbu = pageWindow.IcbuIM;
if (!icbu || typeof icbu !== "object") return null;
const userUtil = (icbu as Record<string, unknown>).UserUtil;
if (!userUtil || typeof userUtil !== "object") return null;
const currentUser = (userUtil as Record<string, unknown>).currentUser;
if (!currentUser || typeof currentUser !== "object") return null;
return accountIdString((currentUser as Record<string, unknown>).accountId);
};
export const readCurrentConversationId = (pageWindow: OneTalkPageWindow): string | null => {
const selected = readSelectedConversationIds(pageWindow);
return selected.length === 1 ? selected[0] : null;
@@ -1,6 +1,6 @@
// 连接 OneTalk MAIN 页面与可信桥接契约
import { readActiveAccountId, readConversationSelection } from "../main-page/page-context.ts";
import { readChannelAccountId, readConversationSelection } from "../main-page/page-context.ts";
import type { HistoryPageProgress } from "../main-page/current-conversation-history/model.ts";
import type { OneTalkObservedMessageSink } from "../main-page/message-observer/model.ts";
import {
@@ -56,7 +56,7 @@ const postPageMessage = (
};
const postPageHello = (pageWindow: OneTalkPageBridgeWindow, origin: string): boolean => {
const channelAccountId = readActiveAccountId(pageWindow);
const channelAccountId = readChannelAccountId(pageWindow);
if (!channelAccountId) return false;
const selection = readConversationSelection(pageWindow);
const message = createOneTalkPageHelloMessage(
@@ -8,6 +8,7 @@ import { syncCurrentConversationHistory } from "../src/onetalk/main-page/current
import { isAccountId } from "../src/onetalk/main-page/model.ts";
const targetAccountId = 2500002169502;
const loginAccountId = "login-account-1";
const targetConversation = {
cid: "conversation-1",
accountId: targetAccountId,
@@ -74,6 +75,7 @@ function createPageWindow({ conversationPages, historyPages }) {
location: {
href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}`,
},
currentUserAccountId: loginAccountId,
IcbuIM: {
IMBaaSSDK: { default: sdk },
},
@@ -392,6 +394,7 @@ test("fails closed when the page context or required SDK is unavailable", async
await assert.rejects(
syncCurrentConversationHistory({
location: { href: `https://onetalk.alibaba.com/?activeAccountId=${targetAccountId}` },
currentUserAccountId: loginAccountId,
}),
/onetalk_history_sdk_unavailable/,
);
@@ -458,8 +461,7 @@ test("stops when the active account changes during a history request", async ()
const originalFetch = fixture.messageService.fetchMessagesWithoutUpdateToRead;
fixture.messageService.fetchMessagesWithoutUpdateToRead = async (...args) => {
const response = await originalFetch(...args);
fixture.pageWindow.location.href =
"https://onetalk.alibaba.com/?activeAccountId=different-account";
fixture.pageWindow.currentUserAccountId = "different-login-account";
return response;
};
@@ -12,6 +12,7 @@ import {
ONE_TALK_PAGE_BRIDGE_VERSION,
ONE_TALK_PAGE_PORT_NAME,
} from "../src/onetalk/page-bridge/model.ts";
import { readChannelAccountId } from "../src/onetalk/main-page/page-context.ts";
import { installOneTalkIsolatedPageBridge } from "../src/onetalk/page-bridge/isolated.ts";
import { installOneTalkMainPageBridge } from "../src/onetalk/page-bridge/main.ts";
import { installOneTalkMessageObserver } from "../src/onetalk/main-page/message-observer/entry.ts";
@@ -52,6 +53,7 @@ class FakePageWindow {
href: `${pageOrigin}/?activeAccountId=account-1`,
origin: pageOrigin,
};
this.currentUserAccountId = "login-account-1";
this.listeners = [];
this.posted = [];
this.throwOnPost = false;
@@ -196,21 +198,39 @@ test("publishes a page registration to the current origin", () => {
source: ONE_TALK_PAGE_BRIDGE_SOURCE,
version: ONE_TALK_PAGE_BRIDGE_VERSION,
type: "onetalk.page.hello",
channelAccountId: "account-1",
channelAccountId: "login-account-1",
},
targetOrigin: pageOrigin,
});
});
test("reads the logged-in account from the OneTalk UserUtil fallback", () => {
const pageWindow = new FakePageWindow();
pageWindow.currentUserAccountId = undefined;
pageWindow.IcbuIM = {
UserUtil: { currentUser: { accountId: 286995452 } },
};
assert.equal(readChannelAccountId(pageWindow), "286995452");
});
test("never falls back to the URL active contact account", () => {
const pageWindow = new FakePageWindow();
pageWindow.currentUserAccountId = undefined;
assert.equal(readChannelAccountId(pageWindow), null);
});
test("retries page registration when the account becomes available, then stops", async () => {
const pageWindow = new FakePageWindow();
pageWindow.location.href = `${pageOrigin}/`;
pageWindow.currentUserAccountId = null;
installOneTalkMainPageBridge(pageWindow, undefined, { retryDelayMs: 1, maxAttempts: 4 });
assert.equal(pageWindow.posted.length, 0);
pageWindow.location.href = `${pageOrigin}/?activeAccountId=late-account`;
pageWindow.currentUserAccountId = "login-late";
await new Promise((resolve) => setTimeout(resolve, 5));
assert.equal(pageWindow.posted.length, 1);
assert.equal(pageWindow.posted[0].message.channelAccountId, "late-account");
assert.equal(pageWindow.posted[0].message.channelAccountId, "login-late");
pageWindow.dispatchPageHide();
pageWindow.location.href = `${pageOrigin}/?activeAccountId=another-account`;
await new Promise((resolve) => setTimeout(resolve, 5));
@@ -35,6 +35,7 @@ const createSendPage = (selectedIds = ["conversation-1"]) => {
location: {
href: `${pageOrigin}/?activeAccountId=account-1&conversationId=url-value`,
},
currentUserAccountId: "login-account-1",
document: {
querySelectorAll: (selector) => {
selectors.push(selector);
@@ -163,6 +164,7 @@ class BridgePageWindow {
href: `${pageOrigin}/?activeAccountId=account-1&conversationId=url-value`,
origin: pageOrigin,
};
this.currentUserAccountId = "login-account-1";
this.selectedIds = ["conversation-1"];
this.posted = [];
this.listeners = [];
@@ -204,14 +206,14 @@ test("refreshes hello with the selected SPA identity after a click without URL c
source: "trade-message-center.onetalk.page-bridge",
version: 1,
type: "onetalk.page.hello",
channelAccountId: "account-1",
channelAccountId: "login-account-1",
conversationId: "conversation-1",
},
{
source: "trade-message-center.onetalk.page-bridge",
version: 1,
type: "onetalk.page.hello",
channelAccountId: "account-1",
channelAccountId: "login-account-1",
conversationId: "conversation-2",
},
],
@@ -8,6 +8,7 @@ const popupHtmlUrl = new URL("../popup/popup.html", import.meta.url);
const popupSourceUrl = new URL("../popup/popup.ts", import.meta.url);
const buildConfigUrl = new URL("../src/onetalk/build-config.ts", import.meta.url);
const viteConfigUrl = new URL("../vite.config.ts", import.meta.url);
const manifestUrl = new URL("../public/manifest.json", import.meta.url);
test("keeps Binding in the identity section and Bright details development-only", async () => {
const html = await readFile(popupHtmlUrl, "utf8");
@@ -20,7 +21,7 @@ test("keeps Binding in the identity section and Bright details development-only"
html.indexOf('<script type="module"'),
);
assert.match(identitySection, /id="active-account-id"/u);
assert.match(identitySection, /id="channel-account-id"/u);
assert.match(identitySection, /id="device-id"/u);
assert.match(identitySection, /id="build-hash"/u);
assert.match(identitySection, /id="binding"/u);
@@ -30,10 +31,11 @@ test("keeps Binding in the identity section and Bright details development-only"
});
test("uses the canonical development flag and subscribes through the shared status guard", async () => {
const [popupSource, buildConfig, viteConfig] = await Promise.all([
const [popupSource, buildConfig, viteConfig, manifest] = await Promise.all([
readFile(popupSourceUrl, "utf8"),
readFile(buildConfigUrl, "utf8"),
readFile(viteConfigUrl, "utf8"),
readFile(manifestUrl, "utf8"),
]);
assert.match(
@@ -42,6 +44,9 @@ test("uses the canonical development flag and subscribes through the shared stat
);
assert.match(popupSource, /developerDetails\.hidden = !IS_ONE_TALK_DEVELOPMENT_MODE/u);
assert.match(popupSource, /chromeApi\.runtime\.onMessage\.addListener/u);
assert.match(popupSource, /chromeApi\.scripting\.executeScript/u);
assert.doesNotMatch(popupSource, /readActiveAccountIdFromUrl/u);
assert.match(manifest, /"scripting"/u);
assert.match(popupSource, /isOneTalkConfigStatusEvent\(message\)/u);
assert.match(popupSource, /buildHashOutput\.textContent = __TMC_BUILD_HASH__/u);
assert.match(viteConfig, /__TMC_BUILD_HASH__: JSON\.stringify\(buildHash\)/u);
+3 -1
View File
@@ -506,6 +506,8 @@ DOM 读取必须以标签和精确容器为锚点,不能按客户显示名、
## 6. ID 映射规则
> 重要:URL 的 `activeAccountId` 是当前选中对话账号(例如 `243340382`),不是登录人。`channelAccountId` 必须读取页面运行时的 `currentUserAccountId` 或 `IcbuIM.UserUtil.currentUser.accountId`(本次探查为 `286995452`)。
### 6.1 当前页面观察到的 ID
当前 Heena Liu 会话中同时出现了:
@@ -570,7 +572,7 @@ channelAccountId + aliId
{
type: "contact.profile.observed",
source: "onetalk.page",
channelAccountId: "243340382",
channelAccountId: "286995452",
conversationId: "2208314000798-2500002169502#11011@icbu",
aliId: "2208314000798",
loginId: "hzhago",
+1 -1
View File
@@ -199,7 +199,7 @@ Bright/Mind 服务端身份与关联上下文只允许以下命名作为当前
- `senderId`
- `loginUserId`
其中 `channelAccountId` 是 OneTalk 页面提供的原始账号 ID。字段具体类型、空值规则和序列化形式必须由服务端 design 与共享契约统一确定;未经新的页面证据和业务批准,不新增 `sellerAccount``channelAccount``sellerAccountId` 等替代概念。
其中 `channelAccountId` 是 OneTalk 页面运行时提供的登录人原始账号 ID`currentUserAccountId``IcbuIM.UserUtil.currentUser.accountId`);URL 的 `activeAccountId` 仅是当前对话账号,不能作为 channel account。字段具体类型、空值规则和序列化形式必须由服务端 design 与共享契约统一确定;未经新的页面证据和业务批准,不新增 `sellerAccount``channelAccount``sellerAccountId` 等替代概念。
插件 OneTalk WebSocket `ws.hello` 的字段子集固定为 `channelAccountId + deviceId`binding 固定放在 `payload.binding`Bright 按 `channelAccountId + binding` 执行授权匹配,deviceId 只用于来源和同一 socket 完整性。`mindUserId``workspaceId` 由 Bright 从 Mind 登录/授权结果或开发授权视图获得,仅用于服务端授权,不进入插件 popup、插件协议或页面事实。
+2 -2
View File
@@ -46,7 +46,7 @@
- Mind 用户可以属于多个 workspace,每个 workspace 可以拥有多个 `channelAccountId` 归属记录;MVP 同一用户只激活其中一个。当前系统没有账号迁移功能,本项目不设计账号迁移或相关数据处理。
- 同一 Mind 用户同一时刻只允许一个有效 binding;binding 接管时 Mind 必须使旧 binding 失效。插件安装实例可用 deviceId 标识来源,但该标识不改变 binding 的授权结论。
- MVP 中 Mind 侧 active binding 的授权作用域为 `mindUserId + workspaceId + channelAccountId``binding``deviceId` 仅是插件安装实例的来源标识和同一连接的完整性字段,不参与 active binding decision;插件不携带也不校验 `mindUserId``workspaceId`。同一 Mind 用户只允许一个 active binding;切换授权范围或 binding 时必须原子撤销旧 binding。
- `channelAccountId` 是 OneTalk 页面提供的原始账号 ID不是 Mind 或 Bright 生成的内部 ID。插件从页面取得该值,并在连接中携带 `channelAccountId + binding + deviceId`;由 Bright/Mind 授权视图精确确认 binding 归属,插件不自行查询 Mind 业务身份。
- `channelAccountId` 是 OneTalk 页面运行时提供的登录人原始账号 ID`currentUserAccountId`,或 `IcbuIM.UserUtil.currentUser.accountId`),不是 URL `activeAccountId` 所指向的当前对话账号,也不是 Mind 或 Bright 生成的内部 ID。插件从页面取得该值,并在连接中携带 `channelAccountId + binding + deviceId`;由 Bright/Mind 授权视图精确确认 binding 归属,插件不自行查询 Mind 业务身份。
- Bright 只通过 Mind DB 的版本化最小只读认证视图读取 binding、设备、user/workspace、账号范围、权限、状态、撤销时间、版本和更新时间;Bright 数据库凭据不得读取 Mind 客户、摘要、负责人、未读、激活码秘密或其它业务表。
- Bright 复用现有 Mind session/token,不建设独立登录系统。Mind 页面访问 Bright HTTP/WebSocket 时携带现有凭证且不得放在 URL,Bright 校验页面侧的 `mindUserId + workspaceId + channelAccountId` 及 read/send 权限;插件连接只提交 `channelAccountId + binding + deviceId`,由 Bright 通过 Mind 授权视图解析并校验服务端 user/workspace 上下文。
- 插件 popup 负责输入并保存 Bright WebSocket URL、`channelAccountId``deviceId``binding``chrome.storage.local`Service Worker 启动时读取,popup 修改或清除后动态重建或关闭连接。`binding` 不得进入 MAIN world、页面 `localStorage` 或消息事实 payload。
@@ -80,7 +80,7 @@
- R7b. 插件必须保留这四项的原始观测来源以供诊断,但不得用 `msgId``messageID``msgIdStr`、通用 `id` 等未经新契约确认的候选字段静默替代缺失的 `messageId`
- R7c. `latest-*``hist_*`、正文/时间/方向 hash 等客户端合成值不得存入 `messageId`,也不得作为去重、增量锚点或重建完成依据。
- R7d. TradeBright 按 `channelAccountId + conversationId + messageId` 建立幂等边界;binding 和 deviceId 只用于证明上传设备获得该账号授权和记录来源,`mindUserId``workspaceId` 由服务端授权适配解析,不进入插件上传帧或幂等键。senderId 与 loginUserId 作为消息属性保存,不做合理性判断。
- R7e. Bright/Mind 服务端上下文只允许:`mindUserId``workspaceId``channelAccountId``deviceId``conversationId``messageId``senderId``loginUserId`。其中 `mindUserId``workspaceId` 仅属于 Mind 授权/业务上下文,不是 OneTalk 页面字段,也不进入插件认证帧;插件认证字段固定为 `channelAccountId``deviceId``binding`。其中 `channelAccountId` 是 OneTalk 页面原始账号 ID。两库必须共享字段名称、类型和空值规则;不得新增 `sellerAccount``channelAccount``sellerAccountId`,除非后续证据证明 OneTalk 提供且业务另行批准。
- R7e. Bright/Mind 服务端上下文只允许:`mindUserId``workspaceId``channelAccountId``deviceId``conversationId``messageId``senderId``loginUserId`。其中 `mindUserId``workspaceId` 仅属于 Mind 授权/业务上下文,不是 OneTalk 页面字段,也不进入插件认证帧;插件认证字段固定为 `channelAccountId``deviceId``binding`。其中 `channelAccountId` 是 OneTalk 页面运行时登录人的原始账号 ID`currentUserAccountId``IcbuIM.UserUtil.currentUser.accountId`),URL `activeAccountId` 只表示当前对话账号。两库必须共享字段名称、类型和空值规则;不得新增 `sellerAccount``channelAccount``sellerAccountId`,除非后续证据证明 OneTalk 提供且业务另行批准。
- R8. 所有缺失或不匹配的页面账号、渠道账号或会话身份必须 fail closed;不得回退到其他 OneTalk 标签页或广播账号特定工作。
- R8a. 设备能否连接、同步或发送由 Mind 的 binding 授权决定;Bright 必须在接受对应操作前通过最小认证视图校验授权,失败或认证数据不可用时拒绝操作。
- R8aa. 插件 `ws.hello` 只携带 `channelAccountId + deviceId`,并在 `payload.binding` 中提交 bindingBright 必须向 Mind 授权视图确认 binding 属于该 `channelAccountId`,再返回 `authorizationVersion``permissions``deviceId` 只用于来源和同一 socket 的 scope 完整性校验,不参与授权匹配。插件不得携带或校验 `mindUserId``workspaceId`