feat: 支持 OneTalk 文本引用回复协议 (#66)

* feat: add OneTalk quote reply support

* chore(task): archive 09-17-onetalk-quote-reply-protocol

* chore: record journal

* docs: format OneTalk DOM card collection plan

* chore: release 0.8.29

* chore: release 0.8.30
This commit is contained in:
YBF
2026-09-17 11:56:35 +08:00
committed by GitHub
parent 826980e97f
commit 784bbec2a0
45 changed files with 1403 additions and 44 deletions
@@ -20,6 +20,16 @@ type OneTalkUiSendInput = {
conversationCode: string;
content: string;
ext?: Record<string, unknown> | null;
/** 可选文本引用;必须由 MAIN 内已验证的 raw source 构建。 */
referMessage?: {
msgId: string;
subType: 1;
senderAliId: string;
senderName: string;
contentAbstract: string;
originalData: Record<string, unknown>;
sendTime?: number;
};
};
const messageService = window.IcbuIM.IMBaaSSDK.default.getMessageService();
@@ -30,6 +40,8 @@ const localAcceptance = await messageService.sendUIMessages(input);
- conversationCode 仅为旧适配器/观测代码保留的兼容字段,当前构造时必须与 cid 相同。SDK 选择目标时不以它为准;只传 conversationCode 会触发 SDK.context.cid 回退并可能发错当前对话。
- content 是要发送的普通字符串;插件只接受协议已收窄的字符串正文。
- ext 可以省略或传 null/对象。SDK 会自行补齐 basicMessageInfo、messageDisplayInfo、messageEventInfo 等内部扩展字段;调用方不得手工拼装转换后的消息对象。
- 文本引用仅由可选顶层 `referMessage` 启用,SDK 不读取 `ext.referMessage`。命令边界只允许规范的 `replyToMessageId`MAIN 必须按当前 `channelAccountId + conversationId + canonicalMessageId` 从短生命周期 raw index 解析该对象。数字 message ID 在 SDK 调用前恢复一次 `.PNM`,但 `.PNM` 别名不得跨 Mind/Center wire。缺少、跨账号/会话或非文本来源一律 `rejected_before_send/invalid_request`,不得退化为普通发送。
- live `lastMessage.message` 原始 envelope 可以没有 `messageType`。仅当该字段缺失时,引用索引可使用已通过主 decoder 的 `observed.direction` 推导 `rec`/`send`;显式出现未知类型、或显式类型与观察方向不一致时必须拒绝。不得从 UI 投影、正文或当前会话拼回方向/发送者。
- 第二个布尔参数不属于发送 SOP,必须省略;SDK 内部默认值为 false,传入其它值可能切换本地消息路径。
SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归一化,再转换为 conversationType: 1、msgType: 101、originalData: { text: content } 及内部 ext。这是页面 SDK 的实现细节,不是插件与 Bright 之间可直接复用的 wire payload。
@@ -37,7 +49,7 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
## 3. Contracts
1. Service Worker 只把 send.command 路由到唯一同账号页面;页面 selected conversation 只用于页面身份/同步,不作为 SDK send gate。
2. MAIN 只调用一次 sendUIMessages({ cid: conversationId, conversationCode: conversationId, content, ext }),不调用 sendMessage、send 或 sendTextMessage,不执行 DOM/fallback 发送;`cid``conversationCode` 必须同值。
2. MAIN 只调用一次 sendUIMessages({ cid: conversationId, conversationCode: conversationId, content, ext, referMessage? }),不调用 sendMessage、send 或 sendTextMessage,不执行 DOM/fallback 发送;`cid``conversationCode` 必须同值。raw 引用源不越过 MAIN 的观察/bridge 边界、不写入 IndexedDB 或日志。
3. SDK 调用前登记短生命周期的发送观察关联;现有 OneTalk WebSocket 旁路 observer 直接消费解析后的消息批次,不使用 confirmation waiter 或 window confirmation listener。
4. SDK Promise 返回值只表示本地受理(通常含 opId)。只有 WebSocket 旁路观察到目标会话的完整 direction: sent 消息,才能生成 confirmed_sent。
5. 观察消息必须匹配 command 的目标 conversationId(观察消息中的 conversationId),并通过完整 OneTalkMessage guard。若 SDK 返回候选 message ID,候选 ID 是主关联键:只按目标会话、`direction: "sent"`、完整消息和 ID 匹配确认,不再额外套正文或时间窗口;候选 ID 不匹配时不得退回正文匹配。只有没有候选 ID 时,才按正文或媒体指纹匹配;不得把 `sentAtMs` 早于本地登记 pending 的时刻作为拒绝条件。仅拒绝晚于当前观测时刻五秒以上的未来 `sentAtMs`,防止未来时间戳误关联。
@@ -55,6 +67,9 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
| 页面 selected 会话不是目标 | 仍可向唯一同账号页面投递;目标由 SDK input 的 `cid` 指定 |
| SDK、getMessageService 或 sendUIMessages 缺失 | rejected_before_send/send_not_supported |
| command conversationId 为空、无法生成非空 cid,或 content 非字符串 | rejected_before_send/invalid_request,不调用 SDK |
| replyToMessageId 为空、`.PNM` 别名、跨账号/会话、未加载、非文本或 raw 引用字段不完整 | rejected_before_send/invalid_request,不调用 SDK,也不降级普通文本 |
| live raw message 缺少 messageType,但其方向已由主 decoder 验证 | 仅使用该 direction 建立 MAIN-only 引用源;发送者、名称、originalData 或时间缺失仍拒绝 |
| live raw message 显式 messageType 未知或与已观察 direction 冲突 | 不登记引用源;后续引用请求 rejected_before_send/invalid_request |
| SDK input 只有 conversationCode、缺少 cid | 禁止调用;若漏过边界,SDK 会按 SDK.context.cid 回退到当前打开会话,属于可导致错聊的错误 |
| SDK 调用抛错 | delivery_unknown/send_connection_lost |
| 仅返回 opId/undefined,没有匹配旁路事实 | delivery_unknown/send_state_lost |
@@ -75,12 +90,14 @@ SDK 会先按 `normalized.conversationCode = input.cid || SDK.context.cid` 归
- Good:当前打开 conversation-A、目标为 conversation-B;不改变页面 selected 状态,SDK input 使用 `cid=B`(并保留同值 `conversationCode=B`)。
- Good:SDK 未返回候选 ID 时,旁路观察的完整目标消息即使 `sentAtMs` 早于本地登记 pending 的时刻,仍按正文或媒体指纹确认。
- BaseSDK 返回 { opId } 但观察链尚未闭合,发送合法 delivery_unknown/send_state_lost,不承诺成功。
- Baselive envelope 缺少 `messageType`,但主 decoder 已得出 received/sentindex 仅据此选择 contact/owner 展示名,仍要求完整 raw 引用字段。
- Bad:只传 `conversationCode=B`、把 URL conversationId 当 SPA 会话、调用旧 API、把 opId 当 confirmed_sent、用页面文本当成功证据或自动重试不确定发送。
## 6. Tests Required
- Service Worker:发送只要求唯一同账号页面;当前 selected 为其它会话、零 selected、多 selected 时仍投递;同账号多页面不广播;历史 sync 仍保持精确路由。
- MAIN handler:断言只调用一次 sendUIMessages,参数包含 `cid`、同值的兼容 `conversationCode`、字符串 content 和可选 ext;不调用旧 API、DOM 或 fallback;缺少 cid 的输入不得进入 SDK。
- 引用 source index:覆盖 history 以及没有 `messageType` 的 live envelope;只有经主 decoder 验证的方向可补足缺失类型,显式未知/方向冲突、缺 sender/name/originalData 或跨账号/会话均不得登记,且 raw source 不得进入 bridge/日志/IndexedDB。
- WebSocket observer/correlator:覆盖完整 sent、错误会话、received、不完整消息、候选 ID 匹配(含超出正文时间窗口)/不匹配(不得正文回退)、无候选 ID 的正文或媒体指纹匹配、早于本地 pending 登记时刻的 `sentAtMs` 仍确认、晚于当前观测时刻五秒以上的 `sentAtMs` 拒绝、legacy `sentAt`、超时,以及 text/image/file pending 的唯一匹配仅结算自身、多个命中只结算匹配集合、未命中项继续等待;断言缺失 correlator wiring 不会直调 SDK。
- Service Worker/contract:只接受完整 sent message 作为 confirmed_sentopId/undefined/缺字段/错误方向/缺 reason 均收敛到合法三态。
- 联调 smoke:当前打开非目标会话时发送普通字符串,确认 SDK 由 `cid` 选中目标会话(而不是 SDK.context.cid)、WebSocket sent observation、server inbound confirmation 和随后 send.result;页面出现文本或 SDK 返回 opId 不能作为完成证据。
@@ -0,0 +1,5 @@
{"file":".trellis/tasks/09-17-onetalk-quote-reply-protocol/design.md","reason":"Review the approved protocol schema, Main-world raw-data boundary and cross-layer flow against implementation."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Review that contracts have one owner at each boundary and no consumer reinterprets raw payload."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/send-sop.md","reason":"Review that quote sends preserve existing SDK routing and confirmation semantics."}
{"file":".trellis/spec/chrome-extension/frontend/quality-guidelines.md","reason":"Review Chrome extension type, build and focused test expectations."}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Review canonical OneTalk WebSocket error and fail-closed behavior."}
@@ -0,0 +1,265 @@
# OneTalk 引用回复跨端协议设计
## 1. 结论
引用回复的唯一发送入口是 OneTalk BaaS SDK
```ts
const sdk = window.IcbuIM?.IMBaaSSDK?.default;
const messageService = sdk?.getMessageService?.();
await messageService.sendUIMessages({
cid,
conversationCode: cid,
content,
referMessage,
});
```
SDK 将顶层 `referMessage` 映射为 OneTalk 发送请求中的 `extParam.quoteMessage`。接收/历史转换代码再将其映射回消息的 `extInfo.referMessage`,OneTalk UI 因此渲染引用块。
`messageService.quoteReply(...)` 不是发送引用消息的 API:它只转发 `QUOTE_REPLY` 桥接命令给 PC 端。`sendMessage``sendTextMessage` 和 DOM 点击都不是扩展的稳定发送边界。
## 2. 运行时证据与适用范围
在 2026-09-17 的真实 OneTalk PWA 中,运行时别名 `_imBaaSSDK` 的版本为 `5.0.110`。读取其已加载 `air.js` 可确认:
1. `sendUIMessages(input)` 读取 `input.referMessage`
2. 它产生 `extParam.quoteMessage`,其中 `messageId` 来自 `referMessage.msgId``msgType` 来自 `referMessage.subType`
3. 消息转换器将 `ext.quoteMessage` 还原为 `extInfo.referMessage`
已在该真实会话发送一条 SDK 引用回复,并观察到出站文本与 `quote-wrapper`。该验证只覆盖“当前 selected 会话”的引用序列化和 UI 渲染;它没有验证跨会话路由。跨会话发送必须遵循现有 [PWA 出站发送 SOP](../../spec/chrome-extension/frontend/onetalk/send-sop.md)`cid` 为权威目标,不允许依赖当前 selected 会话或 URL。
扩展代码应继续通过 `window.IcbuIM.IMBaaSSDK.default` 取得 SDK,而不依赖当前页面碰巧存在的 `_imBaaSSDK` 调试别名。
## 3. SDK 输入契约
```ts
type OneTalkSdkQuoteReference = {
/** 原消息的 OneTalk 传输 ID;数字规范 ID 必须补 `.PNM`。 */
msgId: string;
/** 原消息的 OneTalk subType。 */
subType: 1;
/** 原消息发送者的真实 OneTalk aliId。 */
senderAliId: string;
/** OneTalk 引用块展示的发送者名称。 */
senderName: string;
/** OneTalk 引用块展示的摘要。 */
contentAbstract: string;
/** 原始 OneTalk 消息载荷;文本至少为 `{ text: string }`。 */
originalData: Record<string, unknown>;
/** 原消息的 Unix epoch 毫秒;SDK 可选,但扩展应保留。 */
sendTime?: number;
};
type OneTalkSdkQuoteSendInput = {
/** 非空目标 OneTalk 会话代码,SDK 由此选会话。 */
cid: string;
/** 兼容字段;必须严格等于 cid,不能代替 cid。 */
conversationCode: string;
/** 新发出的回复正文。 */
content: string;
/** 必须为顶层字段,SDK 以它生成 quoteMessage。 */
referMessage: OneTalkSdkQuoteReference;
};
```
| 字段 | 必填 | 来源与要求 |
| --- | --- | --- |
| `cid` | 是 | 发起 `send.command` 的权威 `conversationId`,非空字符串。不得从 URL、当前选中卡片或被引用消息的显示文本推断。 |
| `conversationCode` | 是 | 与 `cid` 字节级相等。只提供它会使 SDK 回退到 `SDK.context.cid`,可能发送至当前错误会话。 |
| `content` | 是 | 新的回复正文,非空字符串;它不是被引用的内容。 |
| `referMessage.msgId` | 是 | 被引用原消息的真实 OneTalk ID。若业务层的规范 ID 是纯数字或已被 `normalizeOneTalkMessageId` 去掉后缀,发送前恢复为 `${id}.PNM`;若原值已有 `.PNM`,不得再追加。绝不根据正文、时间或哈希生成 ID。 |
| `referMessage.subType` | 是 | 第一版固定为原消息的 `subType: 1`。任何图片、文件、卡片或其它类型均在 SDK 调用前拒绝。 |
| `referMessage.senderAliId` | 是 | 原消息 `sender.targetId` 的原样字符串。不得用当前会话对象、当前登录账号或 `receiver` 补写。 |
| `referMessage.senderName` | 是 | 原消息方向为 `rec` 时取 `item.contact.name`,方向为 `send` 时取 `item.owner.name`。必须是非空展示字符串。 |
| `referMessage.contentAbstract` | 是 | 文本引用卡片展示摘要,取原始 `originalData.text`;不能以页面投影文本、富内容或结构对象补造。 |
| `referMessage.originalData` | 是 | 原始 OneTalk 文本载荷 `{ text }`,原样保留。不能只传 Mind/Center 的投影 content。 |
| `referMessage.sendTime` | 建议 | 原消息 `sendTime`epoch ms)。当前 SDK 会转发它;保留它能确保后续历史/展示兼容。 |
`receiverAliId` 不是此 `referMessage` 发送契约的一部分。当前 SDK 的 `sendUIMessages` 转换不会从它构造 `quoteMessage`,不能把它当成必填字段或路由字段。
## 4. 可复制调用示例
此例是未来 MAIN-world handler 的 SDK 调用形态。`originalItem` 必须来自已验证的 OneTalk 原始消息;它不是可由 Bright 侧自行拼装的对象。
```ts
type OneTalkMessageItem = {
messageId?: string | number;
uuid?: string | number;
subType: 1;
messageType: "rec" | "send";
sender?: { targetId?: string };
contact?: { name?: string };
owner?: { name?: string };
originalData?: Record<string, unknown>;
content?: string;
sendTime?: number;
};
const quoteMessageId = (value: string | number): string => {
const text = String(value);
return text.endsWith(".PNM") ? text : `${text}.PNM`;
};
const requireNonEmptyString = (value: unknown, field: string): string => {
if (typeof value !== "string" || value.length === 0) {
throw new Error(`invalid_quote_${field}`);
}
return value;
};
const createQuoteReference = (item: OneTalkMessageItem): OneTalkSdkQuoteReference => {
const messageId = item.messageId ?? item.uuid;
if (typeof messageId !== "string" && typeof messageId !== "number") {
throw new Error("invalid_quote_message_id");
}
if (item.subType !== 1) {
throw new Error("unsupported_quote_sub_type");
}
if (!item.originalData || Array.isArray(item.originalData)) {
throw new Error("invalid_quote_original_data");
}
const contentAbstract =
typeof item.originalData.text === "string" && item.originalData.text.length > 0
? item.originalData.text
: requireNonEmptyString(item.content, "content_abstract");
const senderName =
item.messageType === "rec" ? item.contact?.name : item.owner?.name;
return {
msgId: quoteMessageId(messageId),
subType: item.subType,
senderAliId: requireNonEmptyString(item.sender?.targetId, "sender_ali_id"),
senderName: requireNonEmptyString(senderName, "sender_name"),
contentAbstract,
originalData: item.originalData,
...(typeof item.sendTime === "number" ? { sendTime: item.sendTime } : {}),
};
};
export const sendQuoteReply = async (
pageWindow: Window,
targetConversationId: string,
replyText: string,
originalItem: OneTalkMessageItem,
) => {
if (targetConversationId.length === 0 || replyText.length === 0) {
throw new Error("invalid_quote_send_request");
}
const sdk = (pageWindow as any).IcbuIM?.IMBaaSSDK?.default;
const messageService = sdk?.getMessageService?.();
if (typeof messageService?.sendUIMessages !== "function") {
throw new Error("quote_send_not_supported");
}
return messageService.sendUIMessages({
cid: targetConversationId,
conversationCode: targetConversationId,
content: replyText,
referMessage: createQuoteReference(originalItem),
});
};
```
示例中的 `any` 只为突出运行时 SDK 路径;实际实现应扩展 `OneTalkPageWindow` 的显式 SDK 类型,不应把 `any` 引入生产代码。
## 5. Mind 到插件的引用目标协议
Mind 只传一个引用目标标识,不传 SDK `referMessage` 快照。推荐的可选 payload 字段为 `replyToMessageId`:它与 Mind 既有引用术语一致,但这里的值明确是 OneTalk external `messageId`,不是 Mind 数据库 UUID。
```ts
type OneTalkQuoteSendPayload = {
conversationId: string;
content: OneTalkOutboundContent;
/** 规范化的原 OneTalk messageId;省略即普通发送。 */
replyToMessageId?: string;
};
```
| 层 | 责任 |
| --- | --- |
| Mind 时间线 | 从 OneTalk 映射后的 `WorkspaceConversationMessage.id` 读取 `replyToMessageId`。该 `id` 已等于 Center `messageId`,不转换为 Mind UUID,也不添加 `.PNM`。 |
| Mind 实时客户端 | 在 `OneTalkBrightSendMessageRequest``buildOneTalkBrightSendRequestFrame` 中原样发送可选 ID,继续用现有 `conversationId`、scope 和 `sendRequestId` 授权/关联。 |
| shared contract / Center | 允许严格的两种 payload shape:普通 `{ conversationId, content }` 或引用 `{ conversationId, content, replyToMessageId }`;授权和 pending-send 只转发经 decoder 验证的对象。 |
| Service Worker / page bridge | 将同一可选字段从 `send.command` 放入 `onetalk.send` page command;不能放进任意 `ext`。 |
| MAIN | 以 `[channelAccountId, conversationId, canonicalMessageId]` 在私有原始引用索引中解析 SDK `referMessage`,随后将它作为 `sendUIMessages` 的顶层字段传入。 |
当前协议 version 和 payload 键均是严格校验。升级必须让 Mind 的发送帧构建器、Center 共享 contract/server 和插件随同部署;不能期望旧端忽略新字段。版本号本身须以实施时两边正在运行的协议版本为准:当前两个仓库源码显示不同常量,不能在未核对实际部署兼容性的前提下盲目递增。
## 6. MAIN-world 原始引用索引
当前历史/实时解析在 `observedMessage` 后仅发布白名单 `OneTalkMessage`,原始 `originalData` 不会离开 MAIN。实现时应在同一次解析中提取最小引用源,并保留在页面生命周期内:
```ts
type OneTalkRawQuoteSource = {
channelAccountId: string;
conversationId: string;
canonicalMessageId: string;
messageIdForSdk: string;
subType: 1;
messageType: "rec" | "send";
senderAliId: string;
contactName?: string;
ownerName?: string;
content: string;
originalData: Record<string, unknown>;
sendTime: number;
};
```
来源涵盖 SDK flat-history item 与 live message。索引写入发生在现有 history/new message 解析成功时,且只在 `channelAccountId` 未变化时有效。解析 `replyToMessageId` 时必须:
1. 先用 `normalizeOneTalkMessageId` 比较 canonical ID;仅在调用 SDK 前恢复 `.PNM` 传输别名。
2. 要求索引中的 `conversationId` 等于 command `conversationId``channelAccountId` 等于当前 page account。
3. 检查 `subType``senderAliId`、原始数据和方向对应的展示名;任何字段缺失均拒绝,而不是把引用降级成普通文本。
4. 不把此索引发往 ISOLATED、Service Worker、Bright、Mind、日志或 IndexedDB。页面卸载、账号切换或重载时自然失效。
页面引用菜单可展示多种类型,但真实 SDK 端到端发送只验证了文本 `subType: 1`。第一版将此作为产品边界:MAIN 只索引并解析文本引用源,任何其它类型都在 UI、WS validator 与 MAIN lookup 三处拒绝。
## 7. Mind 文本回复交互
Mind 的 `AlibabaMessageTimeline` 对每条满足下列条件的行显示“回复”操作:
- `message.text.trim()` 非空;
- 该行有非空 `message.id`(即 OneTalk `messageId`);
- 该行不是 system、optimistic 或本地发送状态。
点击后,Mind 仅保存 `{ messageId, from, text }` 作为临时 UI state,用 `messageId` 写入 `OneTalkBrightSendMessageRequest.replyToMessageId`。composer 显示发送者与截断后的文本摘要,提供取消按钮;切换会话、成功确认或 pre-send rejection 都清空它。Mind 不接收、保存或展示 OneTalk 的 raw `originalData`,也不尝试在历史列表中重绘远端引用卡。
## 8. 现有扩展的接入缺口
现有 [send.ts](../../../apps/chrome-extension/src/onetalk/main-page/commands/send.ts) 会把普通命令交给 `SendObservationCorrelator.execute`。该关联器当前生成:
```ts
{ cid, conversationCode: cid, content, ext }
```
它没有 `replyToMessageId` 或 top-level `referMessage`,且 SDK 不会读取 `ext.referMessage`。因此后续实现必须新增一个经过显式校验的引用目标和 MAIN-only 解析器,并沿以下单一路径传递:
```text
Mind selected OneTalk messageId
-> Mind send.request payload.replyToMessageId
-> Bright / Center validated send command
-> Service Worker routePageCommand
-> page bridge command
-> MAIN raw quote-source lookup
-> sendUIMessages({ cid, conversationCode, content, referMessage })
```
这应是一个结构性协议变更:引用数据不能隐藏在任意 `ext` 中,也不能由页面根据正文回推。该实现还需沿用既有单份发送观察关联器,不能为引用发送创建第二条确认逻辑。
## 9. 结果与验证
`sendUIMessages` 的 resolve(通常含 `opId`)仅表示 SDK 已本地受理。页面出现引用块同样不足以证明服务端已投递。完成判定仍是:既有 WebSocket 旁路观察到目标 `cid` 上完整的 `direction: "sent"` 消息,并由当前 `SendObservationCorrelator` 唯一关联后产生 `confirmed_sent`
| 场景 | 应有结果 |
| --- | --- |
| SDK / `getMessageService` / `sendUIMessages` 不存在 | `rejected_before_send/send_not_supported`;不点击 DOM,不回退至其它 API。 |
| `cid` 为空,或 `conversationCode !== cid` | `rejected_before_send/invalid_request`;不得调用 SDK。 |
| 引用对象缺字段、`subType` 未验证、ID 不是原消息 ID | `rejected_before_send/invalid_request`;不得生成补偿值。 |
| SDK 抛错 | `delivery_unknown/send_connection_lost`;不自动重试。 |
| SDK 返回 `opId`,未得到旁路 sent 事实 | `delivery_unknown/send_state_lost`。 |
| 旁路收到目标会话的唯一完整 sent 事实 | `confirmed_sent`。 |
推荐联调顺序:先在非当前 selected 会话的场景验证 `cid` 路由,再校验服务端接收到的 `quoteMessage`,最后验证重新拉取/实时消息有 `extInfo.referMessage` 和页面引用块。禁止只凭当前会话 UI 或 `opId` 声称完成。
@@ -0,0 +1,5 @@
{"file":".trellis/tasks/09-17-onetalk-quote-reply-protocol/design.md","reason":"Approved cross-layer quote-reply contract, Main-world raw-data boundary, and SDK input design."}
{"file":".trellis/spec/guides/cross-layer-thinking-guide.md","reason":"Required guidance for one field crossing Mind, Center WebSocket, Service Worker, page bridge and MAIN."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/send-sop.md","reason":"Existing OneTalk SDK routing, confirmation, and fail-closed send rules that the quote path must preserve."}
{"file":".trellis/spec/chrome-extension/frontend/onetalk/page-bridge.md","reason":"MAIN raw-data isolation, exact-shape page commands, and Service Worker routing contract."}
{"file":".trellis/spec/server/backend/error-handling.md","reason":"Canonical OneTalk WebSocket failure and no-fake-success requirements."}
@@ -0,0 +1,44 @@
# OneTalk 文本引用回复实施计划
## Preconditions
- 实施前在两个实际部署的 checkout 确认 Mind 和 Center 当前正在使用的 WebSocket protocol version。仓库快照显示常量不同,不能假定只改一侧即可兼容。
- 只改 OneTalk 文本发送;媒体/富卡引用不进入本计划。
- 在写任何 SDK raw 索引前,确认其生命周期仅限 MAIN world,且不会被现有 `createOneTalkObservedPublisher`、page bridge、日志或 IndexedDB 传播。
## Workstream A — Shared contract and Center
1.`packages/onetalk-contract/src/sending.ts` 为发送 payload 定义可选 `replyToMessageId`;保留普通发送精确 shape,新增文本引用 shape,拒绝空字符串、非文本 content 和未知额外字段。
2. 同步 `isValidOneTalkSendingPayload`、frame type exports、协议版本和 `packages/onetalk-contract/test/contract.test.ts`。旧版本或只有一端携带新字段必须得到现有 protocol/invalid-message 失败,不作静默兼容。
3. 验证 `apps/server/src/websocket/pending-send-coordinator.ts` 仍仅按账号、scope、conversation 和 `sendRequestId` 授权/关联;它可透传验证过的 `replyToMessageId`,但不解析引用源或存储 raw 数据。
4. 更新 `apps/server/test/websocket.test.ts`Mind `send.request` 到 plugin `send.command` 透传文本引用 ID;普通文本不带该字段;空/媒体/额外字段拒绝;失败/超时仍保持三态 payload。
## Workstream B — Plugin MAIN-world resolution
1.`apps/chrome-extension/src/onetalk/main-page/message-observer/` 新增私有文本引用源索引。它以 `channelAccountId + conversationId + normalizeOneTalkMessageId(messageId)` 为键,存储最小的 SDK quote 输入来源,写入仅来自已验证的 raw history/live OneTalk 文本消息。
2. 历史和实时解析在发布白名单观察前,向该索引登记 `messageIdForSdk``subType: 1``senderAliId`、方向对应名称、`originalData`、文本和 `sendTime`。若必要字段不完整,不登记;不得从已发布的 `OneTalkMessage` 反构 `originalData`
3. 账号切换、页面重载、会话不一致时使索引不可用。lookup 要求 command 的 conversation、当前登录 `channelAccountId` 和 canonical message ID 三者一致。
4.`replyToMessageId` 以可选顶层字段从 `send.command` 路由至 page command;扩展 `handleOneTalkSendCommand``SendObservationCorrelator.execute` 的输入,使最终 SDK 输入为 `{ cid, conversationCode: cid, content, referMessage }`
5. 只有 lookup 成功才调用 `sendUIMessages`。引用源缺失、跨账号/会话、非文本或 SDK 不可用时返回 canonical `rejected_before_send/invalid_request``send_not_supported`;绝不降级为普通发送、DOM 点击或私有 EventBus。
6. 保持 `SendObservationCorrelator` 单一确认机制:SDK result 只补充候选 ID,最终确认仍由完整 `direction: sent` 旁路消息决定。
7. 扩展 `apps/chrome-extension/test/onetalk-send-page.test.js``onetalk-send-observation.test.js``onetalk-websocket-tap.test.js` 及新增 focused quote-index test,覆盖输入 shape、MAIN 隔离、`.PNM` 恢复、预发送拒绝、SDK input 和非引用回归。
## Workstream C — Mind text reply UX and WS client
1.`trade-mind/apps/web/src/features/communication/communication-onetalk-bright-types.ts``communication-onetalk-bright-realtime-rules.ts``OneTalkBrightSendMessageRequest`、frame builder 和 reader 增加可选 `replyToMessageId`,并采用已协商的 protocol version。
2.`alibaba-message-timeline.tsx` 添加只针对普通文本消息的回复按钮;该组件只将 `{ id, from, text }` 回调给 workspace,不暴露原始协议数据。
3.`communication-workspace.tsx` 持有当前 OneTalk 文本引用目标,将它传入 `oneTalkBrightRealtime.sendMessage`,并在取消、会话切换、成功与拒绝后清理。为 Alibaba composer 添加引用预览,不增加历史引用卡。
4. 补 Mind focused testsframe writer/parser 的可选字段与拒绝条件、文本行显示回复操作、非文本/系统/optimistic 行不显示、composer 取消和发送 payload、会话切换清理。
## Verification gates
1. Shared contract package tests和 server WebSocket tests(每个后端测试命令必须限制在 60 秒内)。
2. Chrome extension focused testssend handler、raw quote source index、WebSocket/history observer、page bridge flow;再运行对应 type/lint/build 检查。
3. Mind focused unit/render tests和 TypeScript check;在 Mind checkout 验证 WS builder 与当前 Center protocol version 匹配。
4. 真实 Chromium:在当前会话对一条已加载的入站文本发出引用;确认 SDK input 的 `referMessage`、OneTalk 引用 UI、WebSocket complete sent fact 和 Mind `confirmed_sent`。再在未选中的目标会话测试 `cid` 路由。
5. 回归普通文本、图片和文件发送,确认无 `replyToMessageId` 时 payload 与确认语义不变。
## Rollback
- 协议或 raw lookup 任一步不满足时,发布前不启用 Mind 回复入口;已有普通发送保持不带 `replyToMessageId` 的精确 payload。
- 若上线后需回退,统一回退 Mind 与 Center/插件的协议版本;禁止仅回退一端后让新字段被静默吞掉。
@@ -0,0 +1,45 @@
# OneTalk 引用回复跨端协议接入
## Goal
让 Mind 用户能选择一条 OneTalk 时间线消息并发送引用回复,同时保持 OneTalk 原始载荷只在插件 MAIN world 内解析。协议必须在 Mind、Center WebSocket、Service Worker 和页面 SDK 间传递确定的引用目标,不让任一层按正文、时间或显示名称猜测引用内容。
## Confirmed facts
- 2026-09-17 的真实 ChromiumOneTalk PWA)中,`_imBaaSSDK` 版本为 `5.0.110`,其 MessageService 暴露 `sendUIMessages`。读取已加载 SDK 源码确认:顶层 `referMessage` 被转换为发送请求的 `extParam.quoteMessage`,接收侧再还原为 `extInfo.referMessage`
- 已用该 SDK 在当前真实会话中发送一条文本引用回复;SDK 返回本地受理对象,随后页面渲染出 `quote-wrapper` 引用块。该实测证明引用序列化和 UI 渲染,不证明跨会话路由。
- 扩展当前稳定 SDK 入口是 `window.IcbuIM.IMBaaSSDK.default.getMessageService().sendUIMessages`[send.ts](../../../apps/chrome-extension/src/onetalk/main-page/commands/send.ts) 和 [send-sop.md](../../spec/chrome-extension/frontend/onetalk/send-sop.md) 都规定 `cid` 是权威路由字段,`conversationCode` 只能与它同值作为兼容字段。
- 当前 `SendObservationCorrelator.execute` 只将 `cid``conversationCode``content` 和可选 `ext` 传给 SDK。SDK 读取的是顶层 `referMessage`,所以把它放在 `ext.referMessage` 不会启用引用回复。
- 扩展的业务 ID 会把数字 `.PNM` 别名规范化为无后缀形式;引用 SDK 参数需要恢复原始消息 ID 的 `.PNM` 传输形式。
- 当前 `send.request` / `send.command` 的 payload 严格只允许 `conversationId``content`,且所有 WebSocket 帧的 `protocolVersion` 都须精确等于共享 `ONETALK_PROTOCOL_VERSION`。引用 ID 因而需要同时改 shared contract、Mind frame builder/parser、Center 转发和插件 page command;任一方仍使用旧帧都会被拒绝。
- Mind 的 OneTalk HTTP/实时投影将 Center `messageId` 原样赋给时间线消息的 `id``externalMessageId`。它只持有规范化的 `content``senderId`、参与者和时间,不持有 OneTalk SDK 生成引用所需的 `subType``originalData`、展示名等原始字段。
- 插件当前在历史和实时解析时读取 raw OneTalk message,但只跨 MAIN 边界发布白名单后的 `OneTalkMessage`,不保留可按 `[conversationId, messageId]` 查找的引用源。引用功能需要新增仅存在于 MAIN world 的短生命周期索引;raw 数据不得经 WebSocket 发给 Mind 或持久化为第二份业务内容。
- Mind 现有 WhatsApp 引用使用 `replyToMessageId`,但其 HTTP schema 要求 UUID。OneTalk 的 `messageId` 可能是数字字符串,不能直接复用该 UUID 校验或将它误认为 Mind 数据库 ID。
## Requirements
1. Mind 发送请求、Center `send.request` / `send.command` 及插件 page command 增加可选顶层 `replyToMessageId`,值为当前 OneTalk 时间线行的原始 `messageId`;省略时普通发送的 wire shape 与行为保持不变。
2. `replyToMessageId` 只能是非空 OneTalk 业务 ID,必须在 plugin MAIN world 归一化后与同一 `conversationId` 和当前 `channelAccountId` 下的原始来源精确匹配。它不接受 Mind 数据库 UUID、`.PNM` 拼接后的别名、正文、时间或合成 ID。
3. 插件必须从仅 MAIN-world 的原始引用索引解析 SDK `referMessage``msgId``subType``senderAliId``senderName``contentAbstract``originalData``sendTime`。解析不完整、ID 不存在、会话/账号不匹配或类型未验证时 fail closed 为 `rejected_before_send/invalid_request`,且不调用 SDK。
4. 解析成功时唯一的发送入口为 `messageService.sendUIMessages({ cid, conversationCode: cid, content, referMessage })`;不使用 `quoteReply`、DOM 点击、`sendMessage``sendTextMessage`
5. `cid``conversationCode` 的路由语义必须保持现有契约:前者为权威目标,二者必须同值,selected 会话和 URL 均不能替代 `cid`
6. 协议升级必须协调 Mind 与插件版本;共享帧 decoder 对版本和 payload keys 均 fail closed,不能采用“未知字段忽略”的兼容降级。
7. SDK 返回值仍只是本地受理;必须沿用 WebSocket 旁路观察完整 `direction: sent` 消息来确认真实投递。
8. 第一版 Mind UI 在每条已加载的 OneTalk 普通文本消息上提供回复操作;选中后 composer 显示发送者与文本摘要,并允许取消。发送成功、发送前拒绝或当前会话切换时清除引用目标;历史消息本身不新增引用卡展示。
9. 第一版仅允许 `subType: 1` 文本引用(已在真实 Chromium 以 SDK 成功验证)。图片、文件、富卡、系统消息、无正文消息和未解析类型没有回复操作,也不能通过手工帧绕过这一限制。
## Out of scope
- 为非 OneTalk 渠道改变既有 WhatsApp、邮件或通用 TradeBridge 引用协议。
- 向 Mind、Center WebSocket、数据库或日志传递 SDK raw message / `originalData`,或建立第二份 raw 消息存储。
- 用页面私有 `EventBus``quoteReply` 作为扩展发送实现。
- 为未知消息类型虚构 ID、发送者、原始数据或引用摘要。
## Acceptance criteria
- [ ] Mind 选择的时间线消息以其 OneTalk `messageId` 作为可选 `replyToMessageId` 发送,且不将 Mind 数据库 ID 或完整 raw 载荷带入 WS 帧。
- [ ] `send.request``send.command` 和 page command 都以相同可选字段透传,普通发送保持原 payload shape;版本和 exact-key decoder 的联动升级有测试。
- [ ] MAIN-world 索引只保存构建 SDK `referMessage` 所需的短生命周期原始引用数据,按 channel account、conversation 和 canonical message ID 隔离,且不跨桥发布。
- [ ] 有效引用调用包含同值的非空 `cid``conversationCode`、正文和顶层 `referMessage``msgId``.PNM` 规则、`subType``senderAliId``senderName``contentAbstract``originalData``sendTime` 均有测试。
- [ ] 不存在/不完整/跨会话/跨账号/不受支持的 `replyToMessageId` 得到 canonical pre-send rejection,绝不发送普通文本作为降级。
- [ ] 端到端测试覆盖 Mind frame、Center 转发、插件 SDK 输入和已确认发送;`opId` 或页面渲染不能单独作为投递成功证据。
@@ -0,0 +1,26 @@
{
"id": "onetalk-quote-reply-protocol",
"name": "onetalk-quote-reply-protocol",
"title": "OneTalk 引用回复跨端协议接入",
"description": "规划 Mind、WebSocket、插件 MAIN world 与 OneTalk SDK 之间的引用回复协议与解析边界。",
"status": "completed",
"dev_type": null,
"scope": null,
"package": null,
"priority": "P2",
"creator": "ybf",
"assignee": "ybf",
"createdAt": "2026-09-17",
"completedAt": "2026-09-17",
"branch": "tree1",
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
+3 -2
View File
@@ -8,7 +8,7 @@
<!-- @@@auto:current-status -->
- **Active File**: `journal-1.md`
- **Total Sessions**: 79
- **Total Sessions**: 80
- **Last Active**: 2026-09-17
<!-- @@@/auto:current-status -->
@@ -19,7 +19,7 @@
<!-- @@@auto:active-documents -->
| File | Lines | Status |
|------|-------|--------|
| `journal-1.md` | ~1713 | Active |
| `journal-1.md` | ~1724 | Active |
<!-- @@@/auto:active-documents -->
---
@@ -29,6 +29,7 @@
<!-- @@@auto:session-history -->
| # | Date | Title | Commits | Branch |
|---|------|-------|---------|--------|
| 80 | 2026-09-17 | OneTalk 引用回复跨端协议 | `c09de50` | `feat/onetalk-quote-reply-protocol` |
| 79 | 2026-09-17 | Chrome callback 配置响应兼容 | `64e6a59` | `tree2` |
| 78 | 2026-09-17 | 收尾 OneTalk DOM 二次卡片采集 | `e31ba30` | `main` |
| 77 | 2026-09-17 | 收尾 OneTalk React 卡片观察 | `c19e709`, `971f84b` | `main` |
+11
View File
@@ -1712,12 +1712,23 @@ Enforced unique workspace context across Mind authorization HTTP and WebSocket f
### Summary
将 OneTalk Popup 配置消息的 Service Worker listener 改为 callback 加 return true,保留原有配置语义并完成测试、类型检查、构建和独立验收;旧版 Chrome 实机验证待隔离环境执行。
## Session 80: OneTalk 引用回复跨端协议
<!-- trellis-session: v=2 fp=c389e3879425d091 -->
**Date**: 2026-09-17
**Task**: OneTalk 引用回复跨端协议
**Branch**: `feat/onetalk-quote-reply-protocol`
### Summary
完成 OneTalk 文本引用回复的 v9 协议、MAIN-world raw 引用索引、插件发送链路、联调页入口与 Mind 对接文档;契约、服务端、插件测试及类型构建通过。
### Git Commits
| Hash | Message |
|------|---------|
| `64e6a59` | fix: support callback configuration responses |
| `c09de50` | feat: add OneTalk quote reply support |
### Status
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/chrome-extension",
"version": "0.8.28",
"version": "0.8.30",
"private": true,
"type": "module",
"scripts": {
@@ -7,6 +7,7 @@ import { installCurrentConversationHistorySync } from "../current-conversation-h
import { createHistoryMessageBatch } from "../message-observer/history.ts";
import { installOneTalkMessageObserver } from "../message-observer/entry.ts";
import type { OneTalkObservedMessageSink } from "../message-observer/model.ts";
import type { OneTalkQuoteSourceIndex } from "../message-observer/quote-source-index.ts";
import type { OneTalkWebSocketPageWindow } from "../message-observer/websocket.ts";
type OneTalkBuyerFactSink = (facts: OneTalkBuyerFact[], channelAccountId: string) => void;
@@ -16,10 +17,12 @@ export const installOneTalkCollectionObservers = (
pageWindow: OneTalkWebSocketPageWindow,
publish: OneTalkObservedMessageSink,
buyerFactSink: OneTalkBuyerFactSink,
quoteSources?: OneTalkQuoteSourceIndex,
): void => {
installOneTalkBuyerFactDomObserver(pageWindow, buyerFactSink);
installOneTalkMessageObserver(pageWindow, publish);
installOneTalkMessageObserver(pageWindow, publish, quoteSources);
installCurrentConversationHistorySync(pageWindow, {
onHistoryItems: (items) => publish(createHistoryMessageBatch(items, pageWindow)),
onHistoryItems: (items) =>
publish(createHistoryMessageBatch(items, pageWindow, undefined, quoteSources)),
});
};
@@ -4,6 +4,7 @@ import type { OneTalkPageCommandHandler } from "../../page-bridge/main.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { OneTalkObservedMessageSink } from "../message-observer/model.ts";
import type { SendObservationCorrelator } from "../message-observer/send-observation.ts";
import type { OneTalkQuoteSourceIndex } from "../message-observer/quote-source-index.ts";
import type { OneTalkContactProfileObserver } from "../contact-observer/entry.ts";
import { handleOneTalkContactProfileCommand } from "../contact-observer/page-command.ts";
import type { HistoryBootstrapProgressTooltip } from "../current-conversation-history/bootstrap-progress-tooltip.ts";
@@ -12,6 +13,7 @@ import { handleOneTalkSendCommand } from "./send.ts";
export type OneTalkPageCommandDependencies = {
sendObservation: Pick<SendObservationCorrelator, "execute" | "executeImage" | "executeFile">;
quoteSources?: OneTalkQuoteSourceIndex;
observedSink: OneTalkObservedMessageSink;
profileObserver: Pick<OneTalkContactProfileObserver, "snapshot" | "collectConversation">;
historyBootstrapProgress: HistoryBootstrapProgressTooltip;
@@ -25,7 +27,12 @@ export const createOneTalkPageCommandHandler = (
return async (message) => {
switch (message.command.action) {
case "onetalk.send":
return handleOneTalkSendCommand(pageWindow, message, dependencies.sendObservation);
return handleOneTalkSendCommand(
pageWindow,
message,
dependencies.sendObservation,
dependencies.quoteSources,
);
case "onetalk.contact.snapshot":
case "onetalk.contact.collect":
return handleOneTalkContactProfileCommand(
@@ -40,6 +47,7 @@ export const createOneTalkPageCommandHandler = (
message,
dependencies.observedSink,
dependencies.historyBootstrapProgress,
dependencies.quoteSources,
);
default:
return { status: "rejected_before_send", reason: "invalid_request" };
@@ -1,11 +1,16 @@
// 执行页面发送命令并委派媒体适配。
import { isOneTalkOutboundContent, isPlainRecord } from "@trade-message-center/onetalk-contract";
import {
isOneTalkOutboundContent,
isOneTalkReplyToMessageId,
isPlainRecord,
} from "@trade-message-center/onetalk-contract";
import { isObjectRecord } from "../../../lib/guards.ts";
import type { OneTalkPageCommandMessage, PageCommandResult } from "../../page-bridge/model.ts";
import { sendOneTalkFile, sendOneTalkImage } from "../image-send.ts";
import type { OneTalkPageWindow } from "../model.ts";
import type { OneTalkQuoteSourceIndex } from "../message-observer/quote-source-index.ts";
import type { SendObservationCorrelator } from "../message-observer/send-observation.ts";
const isSendExtension = (value: unknown): value is Record<string, unknown> | null | undefined =>
@@ -16,15 +21,19 @@ export const handleOneTalkSendCommand = async (
pageWindow: OneTalkPageWindow,
message: OneTalkPageCommandMessage,
sendObservation: Pick<SendObservationCorrelator, "execute" | "executeImage" | "executeFile">,
quoteSources?: OneTalkQuoteSourceIndex,
): Promise<PageCommandResult> => {
const conversationId = message.command.conversationId;
const content = message.command.content;
const ext = message.command.ext;
const replyToMessageId = message.command.replyToMessageId;
if (
typeof conversationId !== "string" ||
conversationId.length === 0 ||
!isOneTalkOutboundContent(content) ||
!isSendExtension(ext)
!isSendExtension(ext) ||
(replyToMessageId !== undefined &&
(!isOneTalkReplyToMessageId(replyToMessageId) || content.kind !== "text"))
) {
return { status: "rejected_before_send", reason: "invalid_request" };
}
@@ -46,6 +55,13 @@ export const handleOneTalkSendCommand = async (
requestId: message.requestId,
});
}
const referMessage =
replyToMessageId === undefined
? undefined
: (quoteSources?.lookup(pageWindow, conversationId, replyToMessageId) ?? undefined);
if (replyToMessageId !== undefined && !referMessage) {
return { status: "rejected_before_send", reason: "invalid_request" };
}
try {
const sdkRoot = pageWindow.IcbuIM;
if (
@@ -68,8 +84,12 @@ export const handleOneTalkSendCommand = async (
return { status: "rejected_before_send", reason: "send_not_supported" };
}
const sendUIMessages = messageService.sendUIMessages;
return await sendObservation.execute(conversationId, content.text, ext, (input) =>
sendUIMessages.call(messageService, input),
return await sendObservation.execute(
conversationId,
content.text,
ext,
(input) => sendUIMessages.call(messageService, input),
referMessage,
);
} catch {
return { status: "delivery_unknown", reason: "send_connection_lost" };
@@ -13,6 +13,7 @@ import type {
import type { OneTalkPageWindow } from "../model.ts";
import { createHistoryMessageBatch } from "../message-observer/history.ts";
import type { OneTalkObservedMessageSink } from "../message-observer/model.ts";
import type { OneTalkQuoteSourceIndex } from "../message-observer/quote-source-index.ts";
import { syncCurrentConversationHistory, type AllConversationHistoryResult } from "./index.ts";
import { collectDirectConversations, syncCollectedConversation } from "./all-conversations.ts";
import type { ConversationAnchors, ConversationModes, ConversationSyncMode } from "./model.ts";
@@ -123,9 +124,10 @@ const observeHistoryItems = (
items: unknown[],
observedSink?: OneTalkObservedMessageSink,
historyRequestId?: string,
quoteSources?: OneTalkQuoteSourceIndex,
): void => {
if (!observedSink) return;
const batch = createHistoryMessageBatch(items, pageWindow);
const batch = createHistoryMessageBatch(items, pageWindow, undefined, quoteSources);
if (historyRequestId === undefined) {
observedSink(batch);
return;
@@ -148,6 +150,7 @@ export const handleOneTalkHistoryCommand = async (
message: OneTalkPageCommandMessage,
observedSink?: OneTalkObservedMessageSink,
historyBootstrapProgress?: HistoryBootstrapProgressTooltip,
quoteSources?: OneTalkQuoteSourceIndex,
): Promise<PageCommandResult> => {
const action = message.command.action;
if (action === "onetalk.discover-conversations") {
@@ -214,7 +217,13 @@ export const handleOneTalkHistoryCommand = async (
message.requestId,
),
onHistoryItems: (items: unknown[]) =>
observeHistoryItems(pageWindow, items, observedSink, message.requestId),
observeHistoryItems(
pageWindow,
items,
observedSink,
message.requestId,
quoteSources,
),
};
if (action === "onetalk.sync.conversation") {
const conversationId = message.command.conversationId;
@@ -2,11 +2,13 @@
import { installOneTalkWebSocketTap, type OneTalkWebSocketPageWindow } from "./websocket.ts";
import type { OneTalkObservedMessageSink } from "./model.ts";
import type { OneTalkQuoteSourceIndex } from "./quote-source-index.ts";
/** 组合并安装 OneTalk 消息观察入口。 */
export const installOneTalkMessageObserver = (
pageWindow: OneTalkWebSocketPageWindow,
sink?: OneTalkObservedMessageSink,
quoteSources?: OneTalkQuoteSourceIndex,
): void => {
installOneTalkWebSocketTap(pageWindow, sink);
installOneTalkWebSocketTap(pageWindow, sink, quoteSources);
};
@@ -14,9 +14,11 @@ import {
type OneTalkMessageObservationResult,
type OneTalkParsedMessageBatch,
} from "./model.ts";
import type { OneTalkQuoteSourceIndex } from "./quote-source-index.ts";
type HistoryMessageInput = {
message: Record<string, unknown>;
quoteSource: Record<string, unknown>;
readStatus: unknown;
messageStatus: unknown;
shape: "flat" | "wrapper";
@@ -95,6 +97,7 @@ const historyMessageInput = (item: unknown): HistoryMessageInput | null => {
if (wrapperMessage !== undefined) {
return {
message: wrapperMessage,
quoteSource: wrapperMessage,
readStatus: item.readStatus,
messageStatus: item.msgStatus,
shape: "wrapper",
@@ -105,6 +108,7 @@ const historyMessageInput = (item: unknown): HistoryMessageInput | null => {
conversationParticipants(item.conversationCode) ?? participantIdsFromMessage(item);
return {
message: flatHistoryMessage(item, participantIds),
quoteSource: item,
readStatus: item.unread,
messageStatus: item.status,
shape: "flat",
@@ -129,6 +133,7 @@ export const parseHistoryMessages = (
items: unknown[],
pageWindow: OneTalkPageWindow,
tracer: OneTalkMessageTracer = traceOneTalkMessage,
quoteSources?: OneTalkQuoteSourceIndex,
): OneTalkMessageObservationResult[] => {
return items.map((item): OneTalkMessageObservationResult => {
const input = historyMessageInput(item);
@@ -137,7 +142,7 @@ export const parseHistoryMessages = (
const participantIds =
conversationParticipants(input.message.cid) ?? participantIdsFromMessage(input.message);
const selfParticipant = readSelfParticipant(pageWindow, participantIds);
return observedMessage(
const result = observedMessage(
input.message,
participantIds,
input.readStatus,
@@ -147,6 +152,9 @@ export const parseHistoryMessages = (
(trace) => tracer(input.message.messageId, trace),
input.contentSource,
);
if (result.status === "decoded")
quoteSources?.register(pageWindow, input.quoteSource, result.message);
return result;
});
};
@@ -154,6 +162,7 @@ export const createHistoryMessageBatch = (
items: unknown[],
pageWindow: OneTalkPageWindow,
tracer: OneTalkMessageTracer = traceOneTalkMessage,
quoteSources?: OneTalkQuoteSourceIndex,
): OneTalkParsedMessageBatch => {
return createParsedMessageBatch(parseHistoryMessages(items, pageWindow, tracer));
return createParsedMessageBatch(parseHistoryMessages(items, pageWindow, tracer, quoteSources));
};
@@ -4,11 +4,13 @@ import { isObjectRecord } from "../../../lib/guards.ts";
import { parseNewMessages } from "./new.ts";
import type { OneTalkPageWindow } from "../model.ts";
import { createParsedMessageBatch, type OneTalkParsedMessageBatch } from "./model.ts";
import type { OneTalkQuoteSourceIndex } from "./quote-source-index.ts";
/** 识别响应帧并委派到历史或新消息解析器。 */
export const parseOneTalkMessages = (
pageWindow: OneTalkPageWindow,
data: unknown,
quoteSources?: OneTalkQuoteSourceIndex,
): OneTalkParsedMessageBatch => {
if (typeof data !== "string") return createParsedMessageBatch([]);
@@ -28,6 +30,6 @@ export const parseOneTalkMessages = (
return createParsedMessageBatch([]);
return Array.isArray(frame.body)
? createParsedMessageBatch(parseNewMessages(frame.body, pageWindow))
? createParsedMessageBatch(parseNewMessages(frame.body, pageWindow, quoteSources))
: createParsedMessageBatch([]);
};
@@ -8,10 +8,12 @@ import {
participantIdsFromValue,
type OneTalkMessageObservationResult,
} from "./model.ts";
import type { OneTalkQuoteSourceIndex } from "./quote-source-index.ts";
const parseNewMessage = (
conversation: Record<string, unknown>,
pageWindow: OneTalkPageWindow,
quoteSources?: OneTalkQuoteSourceIndex,
): OneTalkMessageObservationResult | null => {
const lastMessage = conversation.lastMessage;
const singleChatConversation = conversation.singleChatConversation;
@@ -27,7 +29,7 @@ const parseNewMessage = (
: participantIdsFromValue(singleChatConversation.participantIds);
const selfParticipant = readSelfParticipant(pageWindow, participantIds);
return observedMessage(
const result = observedMessage(
lastMessage.message,
participantIds,
lastMessage.readStatus,
@@ -35,17 +37,21 @@ const parseNewMessage = (
"new",
selfParticipant,
);
if (result.status === "decoded")
quoteSources?.register(pageWindow, lastMessage.message, result.message);
return result;
};
/** 将新消息响应条目转换为可观察消息。 */
export const parseNewMessages = (
items: unknown[],
pageWindow: OneTalkPageWindow,
quoteSources?: OneTalkQuoteSourceIndex,
): OneTalkMessageObservationResult[] => {
const messages: OneTalkMessageObservationResult[] = [];
for (const item of items) {
if (!isObjectRecord(item) || !isObjectRecord(item.singleChatUserConversation)) continue;
const message = parseNewMessage(item.singleChatUserConversation, pageWindow);
const message = parseNewMessage(item.singleChatUserConversation, pageWindow, quoteSources);
if (message) messages.push(message);
}
return messages;
@@ -0,0 +1,189 @@
// 在 MAIN world 内保存构建 OneTalk SDK 文本引用所需的最小原始来源。
import { isPlainRecord } from "@trade-message-center/onetalk-contract";
import { normalizeOneTalkMessageId } from "../../message-id.ts";
import { readChannelAccountId } from "../page-context.ts";
import type { OneTalkPageWindow, OneTalkSdkQuoteReference } from "../model.ts";
import type { ObservedOneTalkMessage } from "./model.ts";
type OneTalkRawQuoteSource = {
channelAccountId: string;
conversationId: string;
canonicalMessageId: string;
messageIdForSdk: string;
subType: 1;
messageType: "rec" | "send";
senderAliId: string;
contactName?: string;
ownerName?: string;
originalData: Record<string, unknown>;
sendTime: number;
};
export type OneTalkQuoteSourceIndex = {
register: (
pageWindow: OneTalkPageWindow,
rawMessage: unknown,
observed: ObservedOneTalkMessage,
) => void;
lookup: (
pageWindow: OneTalkPageWindow,
conversationId: string,
replyToMessageId: string,
) => OneTalkSdkQuoteReference | null;
};
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.length > 0;
const quoteSourceKey = (
channelAccountId: string,
conversationId: string,
canonicalMessageId: string,
): string => `${channelAccountId}\u0000${conversationId}\u0000${canonicalMessageId}`;
const sdkMessageId = (value: string): string => (/^[0-9]+$/u.test(value) ? `${value}.PNM` : value);
const rawSourceFrom = (
channelAccountId: string,
rawMessage: unknown,
observed: ObservedOneTalkMessage,
): OneTalkRawQuoteSource | null => {
if (!isPlainRecord(rawMessage) || observed.content.kind !== "text") return null;
const rawMessageId = rawMessage.messageId;
const canonicalMessageId = normalizeOneTalkMessageId(rawMessageId);
const conversationId =
typeof rawMessage.conversationCode === "string"
? rawMessage.conversationCode
: rawMessage.cid;
const sender = isPlainRecord(rawMessage.sender) ? rawMessage.sender : null;
const contact = isPlainRecord(rawMessage.contact) ? rawMessage.contact : null;
const owner = isPlainRecord(rawMessage.owner) ? rawMessage.owner : null;
const originalData = rawMessage.originalData;
const rawMessageType = rawMessage.messageType;
const observedMessageType =
observed.direction === "received" ? "rec" : observed.direction === "sent" ? "send" : null;
const messageType =
rawMessageType === "rec" || rawMessageType === "send"
? rawMessageType
: rawMessageType === undefined
? observedMessageType
: null;
const sendTime = rawMessage.sendTime ?? rawMessage.createAt;
if (
(typeof rawMessageId !== "string" && typeof rawMessageId !== "number") ||
!canonicalMessageId ||
canonicalMessageId !== observed.messageId ||
conversationId !== observed.conversationId ||
rawMessage.subType !== 1 ||
!observedMessageType ||
!messageType ||
messageType !== observedMessageType ||
!sender ||
!isNonEmptyString(sender.targetId) ||
!isPlainRecord(originalData) ||
!isNonEmptyString(originalData.text) ||
typeof sendTime !== "number" ||
!Number.isSafeInteger(sendTime) ||
sendTime < 0 ||
sendTime !== observed.sentAtMs
) {
return null;
}
const contactName = isNonEmptyString(contact?.name) ? contact.name : undefined;
const ownerName = isNonEmptyString(owner?.name) ? owner.name : undefined;
if ((messageType === "rec" && !contactName) || (messageType === "send" && !ownerName))
return null;
return {
channelAccountId,
conversationId,
canonicalMessageId,
messageIdForSdk: String(rawMessageId),
subType: 1,
messageType,
senderAliId: sender.targetId,
...(contactName === undefined ? {} : { contactName }),
...(ownerName === undefined ? {} : { ownerName }),
originalData,
sendTime,
};
};
const quoteReferenceFrom = (source: OneTalkRawQuoteSource): OneTalkSdkQuoteReference | null => {
const senderName = source.messageType === "rec" ? source.contactName : source.ownerName;
const contentAbstract = source.originalData.text;
if (!senderName || !isNonEmptyString(contentAbstract)) return null;
return {
msgId: sdkMessageId(source.messageIdForSdk),
subType: source.subType,
senderAliId: source.senderAliId,
senderName,
contentAbstract,
originalData: source.originalData,
sendTime: source.sendTime,
};
};
/**
* 原始 SDK 数据只在此 index 与一次 `sendUIMessages` 调用之间存在;它不会进入观察批次、
* page bridge、日志或 IndexedDB。账号改变即清空整个 index。
*/
export const createOneTalkQuoteSourceIndex = (): OneTalkQuoteSourceIndex => {
const sources = new Map<string, OneTalkRawQuoteSource>();
let activeChannelAccountId: string | null = null;
const syncAccount = (pageWindow: OneTalkPageWindow): string | null => {
const channelAccountId = readChannelAccountId(pageWindow);
if (channelAccountId !== activeChannelAccountId) {
sources.clear();
activeChannelAccountId = channelAccountId;
}
return channelAccountId;
};
return {
register: (pageWindow, rawMessage, observed) => {
const channelAccountId = syncAccount(pageWindow);
if (!channelAccountId) return;
const source = rawSourceFrom(channelAccountId, rawMessage, observed);
if (!source) return;
sources.set(
quoteSourceKey(
source.channelAccountId,
source.conversationId,
source.canonicalMessageId,
),
source,
);
},
lookup: (pageWindow, conversationId, replyToMessageId) => {
const channelAccountId = syncAccount(pageWindow);
const canonicalMessageId = normalizeOneTalkMessageId(replyToMessageId);
if (
!channelAccountId ||
!isNonEmptyString(conversationId) ||
!canonicalMessageId ||
/^[0-9]+\.PNM$/u.test(replyToMessageId)
) {
return null;
}
const source = sources.get(
quoteSourceKey(channelAccountId, conversationId, canonicalMessageId),
);
if (
!source ||
source.channelAccountId !== channelAccountId ||
source.conversationId !== conversationId ||
source.canonicalMessageId !== canonicalMessageId
) {
return null;
}
return quoteReferenceFrom(source);
},
};
};
@@ -5,6 +5,7 @@ import { isObjectRecord } from "../../../lib/guards.ts";
import { traceOneTalkImageSend } from "../../diagnostics/image-send-trace.ts";
import { normalizeOneTalkMessageId } from "../../message-id.ts";
import type { JsonObject, PageCommandResult } from "../../page-bridge/model.ts";
import type { OneTalkSdkQuoteReference } from "../model.ts";
import type { ObservedOneTalkMessage } from "./model.ts";
const DEFAULT_TIMEOUT_MS = 10_000;
@@ -66,7 +67,9 @@ export type SendObservationCorrelator = {
conversationCode: string;
content: string;
ext?: JsonObject | null;
referMessage?: OneTalkSdkQuoteReference;
}) => unknown,
referMessage?: OneTalkSdkQuoteReference,
) => Promise<PageCommandResult>;
executeImage: (
conversationId: string,
@@ -198,7 +201,7 @@ export const createSendObservationCorrelator = (
};
return {
execute: async (conversationId, content, ext, send) => {
execute: async (conversationId, content, ext, send, referMessage) => {
const result = await new Promise<PageCommandResult>((resolve) => {
const record: PendingSend = {
kind: "text",
@@ -221,6 +224,7 @@ export const createSendObservationCorrelator = (
conversationCode: conversationId,
content,
...(ext === undefined ? {} : { ext }),
...(referMessage === undefined ? {} : { referMessage }),
}),
)
.then((candidate) => {
@@ -9,6 +9,7 @@ import { isObjectRecord } from "../../../lib/guards.ts";
import type { OneTalkPageWindow } from "../model.ts";
import { parseOneTalkMessages } from "./index.ts";
import type { OneTalkObservedMessageSink } from "./model.ts";
import type { OneTalkQuoteSourceIndex } from "./quote-source-index.ts";
const INSTALL_KEY = "__tradeMessageCenterOneTalkWebSocketTapInstalled";
const LOG_PREFIX = "[Trade Message Center][OneTalk WebSocket]";
@@ -41,6 +42,7 @@ export interface OneTalkWebSocketPageWindow extends OneTalkPageWindow, WebSocket
export const installOneTalkWebSocketTap = (
pageWindow: OneTalkWebSocketPageWindow,
sink?: OneTalkObservedMessageSink,
quoteSources?: OneTalkQuoteSourceIndex,
): void => {
if (pageWindow[INSTALL_KEY]) return;
pageWindow[INSTALL_KEY] = true;
@@ -52,7 +54,7 @@ export const installOneTalkWebSocketTap = (
(data) => {
try {
if (isHeartbeatResponse(data)) return;
const batch = parseOneTalkMessages(pageWindow, data);
const batch = parseOneTalkMessages(pageWindow, data, quoteSources);
if (
batch.messages.length > 0 ||
batch.diagnostics.unsupportedSkippedCount > 0 ||
@@ -2,6 +2,28 @@
export type OneTalkAccountId = string | number;
export type OneTalkSdkQuoteReference = {
msgId: string;
subType: 1;
senderAliId: string;
senderName: string;
contentAbstract: string;
originalData: Record<string, unknown>;
sendTime?: number;
};
export type OneTalkSdkSendInput = {
cid: string;
conversationCode: string;
content: string;
ext?: Record<string, unknown> | null;
referMessage?: OneTalkSdkQuoteReference;
};
export type OneTalkSdkMessageService = {
sendUIMessages: (input: OneTalkSdkSendInput) => unknown;
};
export type OneTalkPageWindow = {
location: Pick<Location, "href">;
document?: Pick<Document, "querySelectorAll" | "addEventListener">;
@@ -9,6 +9,7 @@ import { ConnectionStatusTooltip } from "./connection-status-tooltip.ts";
import { BindingStatusTooltip } from "./binding-status-tooltip.ts";
import { HistoryBootstrapProgressTooltip } from "./current-conversation-history/bootstrap-progress-tooltip.ts";
import { createSendObservationCorrelator } from "./message-observer/send-observation.ts";
import { createOneTalkQuoteSourceIndex } from "./message-observer/quote-source-index.ts";
import { installOneTalkContactProfileObserver } from "./contact-observer/entry.ts";
import { installOneTalkCollectionObservers } from "./collection/entry.ts";
import { installOneTalkRenderedCardObserver } from "./card-observer/entry.ts";
@@ -32,6 +33,7 @@ const installOneTalkPageFeatures = (): void => {
const bindingStatus = new BindingStatusTooltip(tooltip);
installOneTalkConversationIdCopyControl(window);
const sendObservation = createSendObservationCorrelator();
const quoteSources = createOneTalkQuoteSourceIndex();
const profileObserver = installOneTalkContactProfileObserver(
window,
createOneTalkPageProfileObservedSink(window),
@@ -42,6 +44,7 @@ const installOneTalkPageFeatures = (): void => {
const publish = createOneTalkObservedPublisher(observedSink, sendObservation.observe);
const onCommand = createOneTalkPageCommandHandler(window, {
sendObservation,
quoteSources,
observedSink: publish,
profileObserver,
historyBootstrapProgress,
@@ -53,7 +56,7 @@ const installOneTalkPageFeatures = (): void => {
(disconnected) => connectionStatus.update(disconnected),
(unbound) => bindingStatus.update(unbound),
);
installOneTalkCollectionObservers(window, publish, buyerFactSink);
installOneTalkCollectionObservers(window, publish, buyerFactSink, quoteSources);
installOneTalkRenderedCardObserver(window, renderedCardSink);
};
@@ -57,6 +57,9 @@ export const createOneTalkSendCommandFlow = (options: {
action: "onetalk.send",
conversationId: frame.payload.conversationId,
content: frame.payload.content,
...(frame.payload.replyToMessageId === undefined
? {}
: { replyToMessageId: frame.payload.replyToMessageId }),
},
})
.then((result) => {
@@ -0,0 +1,198 @@
// 验证 MAIN-only OneTalk 引用原始来源索引。
import assert from "node:assert/strict";
import test from "node:test";
import { createOneTalkQuoteSourceIndex } from "../src/onetalk/main-page/message-observer/quote-source-index.ts";
import { createHistoryMessageBatch } from "../src/onetalk/main-page/message-observer/history.ts";
import { parseOneTalkMessages } from "../src/onetalk/main-page/message-observer/index.ts";
const conversationId = "buyer-seller#tenant@icbu";
const observed = {
messageType: "history",
upstreamType: 1,
messageId: "123456",
conversationId,
senderId: "buyer@icbu",
direction: "received",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "raw text" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
};
const rawMessage = {
messageId: 123456,
conversationCode: conversationId,
subType: 1,
messageType: "rec",
sender: { targetId: "buyer" },
contact: { name: "Buyer" },
originalData: { text: "raw text", sdkOnly: "kept-main-only" },
sendTime: 1_700_000_000_000,
};
const pageWindow = (accountId = "account-1") => ({
location: { href: "https://onetalk.alibaba.com/" },
currentUserAccountId: accountId,
});
test("resolves a verified raw text source into the SDK-only quote input", () => {
const index = createOneTalkQuoteSourceIndex();
const page = pageWindow();
index.register(page, rawMessage, observed);
assert.deepEqual(index.lookup(page, conversationId, "123456"), {
msgId: "123456.PNM",
subType: 1,
senderAliId: "buyer",
senderName: "Buyer",
contentAbstract: "raw text",
originalData: { text: "raw text", sdkOnly: "kept-main-only" },
sendTime: 1_700_000_000_000,
});
assert.equal(index.lookup(page, conversationId, "123456.PNM"), null);
});
test("uses the owner name for a verified outgoing text quote source", () => {
const index = createOneTalkQuoteSourceIndex();
const page = pageWindow();
index.register(
page,
{
messageId: "outgoing-1",
cid: conversationId,
subType: 1,
messageType: "send",
sender: { targetId: "seller" },
owner: { name: "Seller" },
originalData: { text: "outgoing raw text" },
createAt: 1_700_000_000_010,
},
{
messageType: "new",
upstreamType: 1,
messageId: "outgoing-1",
conversationId,
senderId: "seller@icbu",
direction: "sent",
sentAtMs: 1_700_000_000_010,
content: { version: 1, kind: "text", text: "outgoing raw text" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
},
);
assert.equal(index.lookup(page, conversationId, "outgoing-1")?.senderName, "Seller");
});
test("rejects incomplete, mismatched, cross-conversation, and account-switched quote sources", () => {
const index = createOneTalkQuoteSourceIndex();
const page = pageWindow();
index.register(page, { ...rawMessage, originalData: { text: "" } }, observed);
assert.equal(index.lookup(page, conversationId, "123456"), null);
index.register(page, rawMessage, observed);
assert.equal(index.lookup(page, "other-conversation", "123456"), null);
assert.equal(index.lookup(pageWindow("account-2"), conversationId, "123456"), null);
});
test("registers only validated text sources from both history and live parsers", () => {
const index = createOneTalkQuoteSourceIndex();
const page = {
...pageWindow(),
__conversationListFullData__: [{ owner: { accountId: "account-1", aliId: "seller" } }],
};
const historyRaw = {
...rawMessage,
type: 1,
msgType: 101,
status: 1,
unread: 0,
content: "SDK display text",
};
const history = createHistoryMessageBatch([historyRaw], page, undefined, index);
assert.equal(history.messages.length, 1);
assert.equal(index.lookup(page, conversationId, "123456")?.msgId, "123456.PNM");
const liveRaw = {
messageId: "live-1",
cid: conversationId,
createAt: 1_700_000_000_001,
subType: 1,
messageType: "rec",
sender: { uid: "buyer@icbu", targetId: "buyer" },
contact: { name: "Buyer" },
originalData: { text: "live raw text" },
content: { contentType: 1, text: { content: "live raw text" } },
unreadCount: 0,
};
const live = parseOneTalkMessages(
page,
JSON.stringify({
code: 200,
body: [
{
singleChatUserConversation: {
lastMessage: { message: liveRaw, readStatus: 0, msgStatus: 1 },
singleChatConversation: {
pairFirst: "buyer@icbu",
pairSecond: "seller@icbu",
},
},
},
],
}),
index,
);
assert.equal(live.messages.length, 1);
assert.equal(index.lookup(page, conversationId, "live-1")?.contentAbstract, "live raw text");
});
test("registers a live text source when the live envelope omits the history messageType", () => {
const index = createOneTalkQuoteSourceIndex();
const page = {
...pageWindow(),
__conversationListFullData__: [{ owner: { accountId: "account-1", aliId: "seller" } }],
};
const liveRaw = {
type: 1,
messageId: "live-without-message-type",
cid: conversationId,
createAt: 1_700_000_000_001,
subType: 1,
sender: { uid: "buyer@icbu", targetId: "buyer" },
contact: { name: "Buyer" },
originalData: { text: "live raw text" },
content: { contentType: 1, text: { content: "live raw text" } },
unreadCount: 0,
};
const live = parseOneTalkMessages(
page,
JSON.stringify({
code: 200,
body: [
{
singleChatUserConversation: {
lastMessage: { message: liveRaw, readStatus: 0, msgStatus: 1 },
singleChatConversation: {
pairFirst: "buyer@icbu",
pairSecond: "seller@icbu",
},
},
},
],
}),
index,
);
assert.equal(live.messages.length, 1);
assert.equal(
index.lookup(page, conversationId, "live-without-message-type")?.msgId,
"live-without-message-type",
);
});
@@ -0,0 +1,62 @@
// 验证 Service Worker 将经 contract 验证的引用目标原样路由给 MAIN。
import assert from "node:assert/strict";
import test from "node:test";
import { ONETALK_PROTOCOL_VERSION } from "@trade-message-center/onetalk-contract";
import { createOneTalkSendCommandFlow } from "../src/onetalk/service-worker/flows/send-command-flow.ts";
const pluginScope = { channelAccountId: "account-1", deviceId: "device-1" };
test("forwards replyToMessageId only as the explicit page send command field", async () => {
const routes = [];
const frames = [];
const flow = createOneTalkSendCommandFlow({
scope: pluginScope,
bright: { send: (frame) => (frames.push(frame), true) },
pageRuntime: {
routePageCommand: async (route) => {
routes.push(route);
return { status: "delivery_unknown", reason: "send_state_lost" };
},
},
createRequestId: () => "confirmation-1",
onError: (error) => {
throw error;
},
});
flow.handle({
protocolVersion: ONETALK_PROTOCOL_VERSION,
connectionType: "plugin",
type: "send.command",
requestId: "command-1",
sendRequestId: "send-1",
scope: pluginScope,
payload: {
conversationId: "conversation-1",
content: { kind: "text", text: "reply" },
replyToMessageId: "123456",
},
});
await new Promise((resolve) => setImmediate(resolve));
assert.deepEqual(routes, [
{
channelAccountId: "account-1",
conversationId: "conversation-1",
requestId: "command-1",
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: { kind: "text", text: "reply" },
replyToMessageId: "123456",
},
},
]);
assert.deepEqual(frames[0].payload, {
status: "delivery_unknown",
reason: "send_state_lost",
});
});
@@ -12,6 +12,7 @@ import {
import { createHistoryMessageBatch } from "../src/onetalk/main-page/message-observer/history.ts";
import { installCurrentConversationHistorySync } from "../src/onetalk/main-page/current-conversation-history/entry.ts";
import { createSendObservationCorrelator } from "../src/onetalk/main-page/message-observer/send-observation.ts";
import { createOneTalkQuoteSourceIndex } from "../src/onetalk/main-page/message-observer/quote-source-index.ts";
import { installOneTalkMainPageBridge } from "../src/onetalk/page-bridge/main.ts";
import {
createOneTalkPageObservedMessage,
@@ -169,6 +170,108 @@ test("calls sendUIMessages once with the verified input and never treats opId as
assert.deepEqual(rejectedCalls, []);
});
test("passes a MAIN-only raw text quote as a top-level SDK referMessage", async () => {
const { pageWindow, calls } = createSendPage();
const quoteSources = createOneTalkQuoteSourceIndex();
quoteSources.register(
pageWindow,
{
messageId: 123456,
conversationCode: "conversation-1",
subType: 1,
messageType: "rec",
sender: { targetId: "buyer" },
contact: { name: "Buyer" },
originalData: { text: "quoted raw text" },
sendTime: 1_700_000_000_000,
},
{
messageType: "history",
upstreamType: 1,
messageId: "123456",
conversationId: "conversation-1",
senderId: "buyer@icbu",
direction: "received",
sentAtMs: 1_700_000_000_000,
content: { version: 1, kind: "text", text: "quoted raw text" },
participantIds: ["buyer@icbu", "seller@icbu"],
readStatus: 0,
messageStatus: 1,
unreadCount: 0,
},
);
assert.deepEqual(
await handleOneTalkSendCommand(
pageWindow,
{
command: {
action: "onetalk.send",
conversationId: "conversation-1",
content: { kind: "text", text: "reply" },
replyToMessageId: "123456",
},
},
createSendObservationCorrelator(1),
quoteSources,
),
{ status: "delivery_unknown", reason: "send_state_lost" },
);
assert.deepEqual(calls, [
{
cid: "conversation-1",
conversationCode: "conversation-1",
content: "reply",
referMessage: {
msgId: "123456.PNM",
subType: 1,
senderAliId: "buyer",
senderName: "Buyer",
contentAbstract: "quoted raw text",
originalData: { text: "quoted raw text" },
sendTime: 1_700_000_000_000,
},
},
]);
});
test("rejects missing and non-text quote sources before invoking the SDK", async () => {
const { pageWindow, calls } = createSendPage();
const quoteSources = createOneTalkQuoteSourceIndex();
for (const command of [
{
action: "onetalk.send",
conversationId: "conversation-1",
content: { kind: "text", text: "reply" },
replyToMessageId: "missing",
},
{
action: "onetalk.send",
conversationId: "conversation-1",
content: {
kind: "image",
source: {
downloadUrl: "https://mind.example.test/bridge/image-1",
fileName: "image.jpg",
mimeType: "image/jpeg",
},
},
replyToMessageId: "123456",
},
]) {
assert.deepEqual(
await handleOneTalkSendCommand(
pageWindow,
{ command },
createSendObservationCorrelator(1),
quoteSources,
),
{ status: "rejected_before_send", reason: "invalid_request" },
);
}
assert.deepEqual(calls, []);
});
test("does not use selected conversation as a send gate", async () => {
const empty = createSendPage([]);
assert.deepEqual(await handleSendCommand(empty.pageWindow), {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/mind-test-harness",
"version": "0.8.28",
"version": "0.8.30",
"private": true,
"type": "module",
"scripts": {
+13 -1
View File
@@ -30,6 +30,7 @@ export const harnessEventsScript = String.raw` const writeCookie = ()
state.cursor = null;
state.hasMore = false;
state.messages.clear();
clearReplyTarget();
state.pendingSendRequestId = null;
setPluginStatus('offline');
fields.loadNext.disabled = true;
@@ -41,10 +42,21 @@ export const harnessEventsScript = String.raw` const writeCookie = ()
fields.loadMessages.addEventListener('click', () => loadMessages(false, false));
fields.loadNext.addEventListener('click', () => loadMessages(false, true));
fields.reconnect.addEventListener('click', () => loadMessages(true));
fields.clearReply.addEventListener('click', clearReplyTarget);
fields.messages.addEventListener('click', (event) => {
const target = event.target instanceof Element
? event.target.closest('.reply-message')
: null;
if (!(target instanceof HTMLButtonElement)) return;
selectReplyTarget(target.dataset.replyToMessageId || '');
});
fields.send.addEventListener('click', () => {
const content = fields.sendContent.value.trim();
if (!content) return;
if (requestSend({ kind: 'text', text: content })) fields.sendContent.value = '';
if (requestSend({ kind: 'text', text: content }, state.replyToMessageId)) {
fields.sendContent.value = '';
clearReplyTarget();
}
});
fields.sendImage.addEventListener('click', () => {
const content = imageContentFromUrl(fields.imageSourceUrl.value);
+16 -1
View File
@@ -56,6 +56,20 @@ export const harnessMessagesScript = String.raw` const messageKey = (
return '<div class="message-content"><p>' + escapeHtml(content.fileName) + '</p><p class="message-meta">' + escapeHtml(content.extension + ' · ' + formatSize(content.sizeBytes) + ' · ' + content.downloadState) + '</p>' + action + '</div>';
};
const clearReplyTarget = () => {
state.replyToMessageId = null;
fields.replyTarget.textContent = '';
fields.replyContext.hidden = true;
};
const selectReplyTarget = (messageId) => {
const message = Array.from(state.messages.values()).find((candidate) => candidate.messageId === messageId);
if (!message) return;
state.replyToMessageId = message.messageId;
fields.replyTarget.textContent = '正在引用消息:' + message.messageId;
fields.replyContext.hidden = false;
};
const renderMessages = () => {
if (state.messages.size === 0) {
fields.messages.hidden = true;
@@ -68,7 +82,8 @@ export const harnessMessagesScript = String.raw` const messageKey = (
fields.messages.innerHTML = Array.from(state.messages.values()).map((message) => {
const sentAt = Math.abs(message.sentAtMs) <= 8640000000000000 ? new Date(message.sentAtMs).toISOString() : 'invalid sentAtMs';
const normalized = JSON.stringify(message.content, null, 2);
return '<li><strong>' + escapeHtml(message.direction) + ' · ' + escapeHtml(sentAt) + '</strong>' + renderContent(message.content) + '<pre>' + escapeHtml(normalized) + '</pre></li>';
const action = '<div class="message-actions"><button class="reply-message secondary slim" type="button" data-reply-to-message-id="' + escapeHtml(message.messageId) + '">引用此消息</button></div>';
return '<li><strong>' + escapeHtml(message.direction) + ' · ' + escapeHtml(sentAt) + '</strong>' + renderContent(message.content) + action + '<pre>' + escapeHtml(normalized) + '</pre></li>';
}).join('');
};
@@ -51,6 +51,10 @@ export const harnessPageMarkup = String.raw` <main>
<div class="send-group inline">
<label>发送内容<input id="send-content" autocomplete="off" placeholder="输入要发送的文本"></label>
<button id="send" class="secondary" type="button" disabled>发送</button>
<div id="reply-context" class="reply-context span-2" aria-live="polite" hidden>
<span id="reply-target"></span>
<button id="clear-reply" class="secondary slim" type="button">取消引用</button>
</div>
</div>
<div class="send-group inline">
<label>已上传图片 OSS URL<input id="image-source-url" autocomplete="off" inputmode="url" placeholder="https://bucket.oss.example.com/path/image.jpg?..." spellcheck="false"></label>
@@ -143,6 +143,7 @@ export const harnessReadingScript = String.raw` const requestedChanne
state.listQuery = '';
state.conversations.clear();
state.messages.clear();
clearReplyTarget();
state.pendingSendRequestId = null;
setPluginStatus('offline');
fields.loadNext.disabled = true;
@@ -212,7 +213,10 @@ export const harnessReadingScript = String.raw` const requestedChanne
closeSocket();
if (!append) state.cursor = null;
state.hasMore = false;
if (!append) state.messages.clear();
if (!append) {
state.messages.clear();
clearReplyTarget();
}
renderMessages();
setStatus(fields.historyStatus, recoverAll ? '重连前恢复完整消息…' : append ? '加载下一页…' : '加载消息中…', 'warn');
let pages = 0;
@@ -25,6 +25,7 @@ export const harnessRuntimeScript = String.raw` const heartbeatInterv
requestSequence: 0,
pluginOnline: false,
pendingSendRequestId: null,
replyToMessageId: null,
uploadInFlight: false,
uploadedUrl: '',
wsFrames: [],
@@ -45,6 +46,9 @@ export const harnessRuntimeScript = String.raw` const heartbeatInterv
reconnect: element('reconnect'),
send: element('send'),
sendContent: element('send-content'),
replyContext: element('reply-context'),
replyTarget: element('reply-target'),
clearReply: element('clear-reply'),
imageSourceUrl: element('image-source-url'),
sendImage: element('send-image'),
fileSourceUrl: element('file-source-url'),
@@ -43,6 +43,7 @@ export const harnessStyles = String.raw` :root { color-scheme: light; fon
.send-group { display: grid; gap: 8px; padding: 10px; background: #fafbfc; border: 1px solid #e4e9ee; border-radius: 8px; min-width: 0; }
.send-group.inline { grid-template-columns: minmax(0, 1fr) auto; align-items: end; }
.send-group.split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.reply-context { display: flex; align-items: center; justify-content: space-between; gap: 8px; border-top: 1px solid #e4e9ee; padding-top: 8px; color: #52606d; font-size: 12px; overflow-wrap: anywhere; }
.status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; }
.status { background: white; border: 1px solid #e1e6ec; border-left: 3px solid #b9c4cf; border-radius: 8px; padding: 8px 12px; min-width: 0; }
@@ -77,6 +78,7 @@ export const harnessStyles = String.raw` :root { color-scheme: light; fon
.message-content .message-meta { color: #68737d; font-size: 12px; margin-top: 4px; }
.message-content .media-error { color: #9b1c1c; font-size: 12.5px; margin-top: 6px; }
.message-image { display: block; max-width: min(100%, 560px); max-height: 420px; margin-top: 8px; border: 1px solid #e1e6ec; border-radius: 6px; background: #eef1f4; }
.message-actions { display: flex; margin-top: 8px; }
.file-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
.file-actions a { background: #f0f6ff; border: 1px solid #c4dcfb; border-radius: 6px; color: #1f6feb; font-size: 12.5px; padding: 3px 10px; text-decoration: none; }
.file-actions a:hover { background: #e3effe; }
@@ -72,7 +72,7 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
fields.sendFile.disabled = !enabled;
};
const requestSend = (content) => {
const requestSend = (content, replyToMessageId = null) => {
const scope = mindPageScope();
if (!state.socket || state.socket.readyState !== WebSocket.OPEN || !scope || !state.conversationId || !state.pluginOnline || state.pendingSendRequestId !== null) return false;
const id = nextRequestId('send');
@@ -80,7 +80,11 @@ export const harnessWebsocketScript = String.raw` const frameMatchesF
sendWsFrame(state.socket, {
protocolVersion,
connectionType: 'mind_page', type: 'send.request', requestId: nextRequestId('send-frame'),
sendRequestId: id, scope, payload: { conversationId: state.conversationId, content }
sendRequestId: id, scope, payload: {
conversationId: state.conversationId,
content,
...(replyToMessageId === null ? {} : { replyToMessageId })
}
});
setStatus(fields.syncStatus, '发送请求中…', 'warn');
updateSendAvailability();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/server",
"version": "0.8.28",
"version": "0.8.30",
"private": true,
"type": "module",
"scripts": {
+10 -2
View File
@@ -2084,7 +2084,11 @@ test("reserves a sendRequestId before authorization and dispatches only once", a
requestId: "request-1",
sendRequestId: "duplicate-id",
scope: mindScope,
payload: { conversationId: "conversation-1", content: { kind: "text", text: "hello" } },
payload: {
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
replyToMessageId: "123456",
},
};
const first = registry.requestSend({ mindSocket, frame });
@@ -2141,7 +2145,11 @@ test("reserves a sendRequestId before authorization and dispatches only once", a
});
releaseAuthorization();
await commandPromise;
assert.equal(JSON.parse(sentCommands[0]).type, "send.command");
assert.deepEqual(JSON.parse(sentCommands[0]).payload, {
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
replyToMessageId: "123456",
});
registry.unregister(pluginSocket);
assert.deepEqual(await first, {
status: "delivery_unknown",
@@ -0,0 +1,136 @@
# OneTalk 引用回复:Mind 对接说明
> 适用范围:Mind 已接入 Bright OneTalk WebSocket 的文本发送链路。本说明只增加引用目标字段,不改变连接、授权、普通发送或发送结果协议。
## 1. 能力与边界
Mind 在 OneTalk 时间线中选择一条**文本消息**后,可以发送一条引用该消息的新文本。Mind 只向 Bright 提交被引用消息的 OneTalk `messageId`;Bright 和插件负责在同一账号、同一会话内解析 OneTalk SDK 所需的原始引用对象。
Mind 不得发送、保存或展示下列数据:
- OneTalk SDK `referMessage` / `originalData`
- `.PNM` 后缀的 SDK 传输别名;
- 根据正文、时间、发送人或 UI 序号推导出的 ID;
- Mind 数据库 UUID(它不是 OneTalk `messageId`)。
第一版只支持“引用文本后发送文本”。图片和文件仍走既有普通发送,不能附带引用字段。
## 2. 引用目标的来源
从 Bright 返回的 OneTalk 时间线消息中读取该条消息的 `id` / `messageId`,将其原样作为 `replyToMessageId`
```ts
type ReplyTarget = {
messageId: string; // Bright OneTalk messageId,不是 Mind UUID
from: string;
text: string;
};
```
仅当时间线行满足以下条件时显示“回复”入口:
- 消息正文是非空文本;
- `messageId` 为非空字符串;
- 消息属于当前 OneTalk 会话,且不是 system、optimistic 或本地临时消息。
`messageId` 不做 trim、拼接或格式转换;例如 `123456.PNM` 不是合法的 `replyToMessageId`
## 3. 发送帧增量
沿用既有 `send.request` WebSocket 帧。在当前共享协议版本 **9** 中,引用发送的 payload 必须恰好为以下形状:
```json
{
"conversationId": "c123",
"content": {
"kind": "text",
"text": "这条消息是在回复前文"
},
"replyToMessageId": "123456"
}
```
完整帧示例(`scope``requestId``sendRequestId` 继续使用当前 Mind 的既有生成与关联逻辑):
```json
{
"protocolVersion": 9,
"connectionType": "mind_page",
"type": "send.request",
"requestId": "mind-frame-001",
"sendRequestId": "mind-send-001",
"scope": {
"workspaceId": "workspace-1",
"mindUserId": "mind-user-1",
"channelAccountId": "286995452"
},
"payload": {
"conversationId": "c123",
"content": {
"kind": "text",
"text": "这条消息是在回复前文"
},
"replyToMessageId": "123456"
}
}
```
普通发送保持原样,**省略** `replyToMessageId`
```json
{
"conversationId": "c123",
"content": { "kind": "text", "text": "普通文本" }
}
```
不要把 `replyToMessageId` 放到 `content``ext` 或其他扩展对象中;协议采用严格字段校验,未知字段或错误层级会被拒绝。
## 4. Mind 侧校验
发起引用发送前,Mind 必须确认:
- `conversationId` 是当前会话的 OneTalk `conversationId`
- `replyToMessageId` 非空、没有首尾空白,且不是纯数字加 `.PNM`
- `content.kind === "text"``content.text` 非空;
- 当前 WebSocket scope 中的 `channelAccountId` 与时间线消息所属账号一致。
不要在客户端把无效引用降级为普通文本发送。若本地无法确定目标 ID,应禁用发送并提示用户重新选择消息。
## 5. Composer 交互
点击“回复”后,Mind 只保留临时 UI 状态 `{ messageId, from, text }`:composer 中显示发送者和截断文本摘要,并提供“取消引用”。
以下场景必须清除该临时状态:
- 切换 OneTalk 会话;
- 收到 `confirmed_sent`
- 收到 `rejected_before_send`
收到 `delivery_unknown` 时,不能自动重试或伪造发送成功;应保留既有“结果未知”的可见状态,由用户决定后续操作。
第一版不要求 Mind 解析或重新绘制远端消息里的引用卡片。引用内容由 OneTalk 页面渲染,Mind 时间线仍按 Bright 已返回的规范化消息展示。
## 6. 结果处理
`send.result` 与普通发送使用同一关联键 `sendRequestId`,结果语义不变:
| status | Mind 行为 |
| ---------------------- | --------------------------------------------------- |
| `confirmed_sent` | 将返回的已发送消息并入时间线,清除引用临时状态。 |
| `rejected_before_send` | 显示 `reason`,清除引用临时状态;不得改发普通文本。 |
| `delivery_unknown` | 显示结果未知;不得自动重试、不得假定消息已发送。 |
常见拒绝原因包括 `invalid_request`(引用 ID 不合法、引用目标不在当前会话或不支持的内容类型)、`waiting_for_page``page_not_found``page_identity_mismatch``send_not_supported`。Mind 应直接展示已有的发送错误状态,不自行转换错误码。
## 7. 联调验收
1. 加载 OneTalk 会话与文本消息,确认时间线行使用 Bright 返回的真实 `messageId`
2. 选择一条文本消息,发送引用文本,检查发送帧只有顶层 `payload.replyToMessageId`
3. 确认 `send.result` 与同一个 `sendRequestId` 关联,并仅在 `confirmed_sent` 时显示发送成功。
4. 验证切换会话、取消引用与 `rejected_before_send` 都会清除 composer 的引用状态。
5. 验证普通文本、图片和文件发送均不携带 `replyToMessageId`,原有行为不变。
## 8. 兼容性
Mind、Bright 与 Chrome 插件必须使用兼容的 OneTalk 协议版本部署。当前源码的协议版本为 9;上线前以实际运行中的共享 contract 为准,不要让旧客户端向新协议帧附加未知字段。
@@ -23,21 +23,32 @@
### Task 1: DOM reader and legacy reader removal
**Files:**
- Create: `apps/chrome-extension/src/onetalk/main-page/card-observer/dom-card-reader.ts`
- Delete: `apps/chrome-extension/src/onetalk/main-page/card-observer/react-card-reader.ts`
- Modify: `apps/chrome-extension/src/onetalk/main-page/card-observer/entry.ts`
- Test: `apps/chrome-extension/test/onetalk-rendered-card-reader.test.js`
**Interfaces:**
- Produces: `readOneTalkRenderedCardFromDom(pageWindow, card): OneTalkRenderedCardObservation | null`.
- Consumes: `readConversationSelection`, `normalizeOneTalkProductUrl`, rendered-card validators and fingerprint creator.
- [ ] **Step 1: Replace Fiber fixtures with DOM fixtures and red tests.**
```js
assert.equal(readOneTalkRenderedCardFromDom(page, cardWith({ cardMessageId: "m-1", wrapperMessageId: "m-2" })), null);
assert.equal(
readOneTalkRenderedCardFromDom(
page,
cardWith({ cardMessageId: "m-1", wrapperMessageId: "m-2" }),
),
null,
);
assert.equal(readOneTalkRenderedCardFromDom(page, quoteCard({ cardType: 8 })), null);
assert.equal(readOneTalkRenderedCardFromDom(page, productCard({ rawUrl: "https://bad.example" })), null);
assert.equal(
readOneTalkRenderedCardFromDom(page, productCard({ rawUrl: "https://bad.example" })),
null,
);
```
- [ ] **Step 2: Run the focused reader test and confirm the missing DOM reader fails.**
@@ -50,7 +61,8 @@ node --experimental-strip-types --test apps/chrome-extension/test/onetalk-render
```ts
const messageId = cardMessageId ?? wrapperMessageId;
if (!messageId || (cardMessageId && wrapperMessageId && cardMessageId !== wrapperMessageId)) return null;
if (!messageId || (cardMessageId && wrapperMessageId && cardMessageId !== wrapperMessageId))
return null;
if (!sameOptionalConversation(cardInfo, wrapperInfo)) return null;
```
@@ -59,8 +71,14 @@ Construct only exact `rendered_inquiry`, `rendered_product`, or `rendered_order`
- [ ] **Step 4: Rewrite entry to scan cards and react only to DOM mutations.**
```ts
for (const card of cardsForMutation(record)) publish(readOneTalkRenderedCardFromDom(pageWindow, card));
observer.observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-expinfo", "src", "style"] });
for (const card of cardsForMutation(record))
publish(readOneTalkRenderedCardFromDom(pageWindow, card));
observer.observe(document.documentElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ["data-expinfo", "src", "style"],
});
```
Remove `setInterval`, wrapper rescans, React imports, and the deleted reader file.
@@ -74,18 +92,23 @@ node --experimental-strip-types --test apps/chrome-extension/test/onetalk-render
### Task 2: Remove Fiber-only base evidence from local boundaries
**Files:**
- Modify: `apps/chrome-extension/src/onetalk/page-bridge/{main.ts,model.ts}`
- Modify: `apps/chrome-extension/src/onetalk/service-worker/{rendered-card-coordinator.ts,storage.ts}`
- Test: `apps/chrome-extension/test/{onetalk-page-bridge,onetalk-rendered-card-coordinator,onetalk-sync-storage}.test.js`
**Interfaces:**
- `onetalk.page.rendered-card-observed` contains `{ channelAccountId, observations }` only.
- `OneTalkRenderedCardCoordinator.observe(observations)` persists and flushes observations without base evidence.
- [ ] **Step 1: Add bridge and ledger tests that accept an observation-only message and reject legacy base-evidence fields.**
```js
assert.deepEqual(decodeOneTalkPageMessage({ source, version, type, channelAccountId, observations }), expected);
assert.deepEqual(
decodeOneTalkPageMessage({ source, version, type, channelAccountId, observations }),
expected,
);
assert.equal("baseDirection" in pendingRecord, false);
```
@@ -113,12 +136,14 @@ node --experimental-strip-types --test apps/chrome-extension/test/onetalk-page-b
### Task 3: Remove approved server audit columns without data cleanup
**Files:**
- Modify: `apps/server/src/database/schema/onetalk.ts`
- Modify: `apps/server/src/onetalk/rendered-card-{model,repository,service}.ts`
- Create: generated `apps/server/drizzle/0015_*.sql` and matching `drizzle/meta` files
- Test: `apps/server/test/onetalk-rendered-card-{migration,repository,flow}.test.ts`
**Interfaces:**
- Stored row retains key, `renderedCardContent`, `renderedCardContentFingerprint`, `renderedCardObservedAtMs`, and `conflictCount`.
- Same content stays duplicate; different content increments `conflictCount` and returns conflict without overwriting JSON.
@@ -157,6 +182,7 @@ pnpm --filter @trade-message-center/server db:check
### Task 4: Contract-preserving integration verification
**Files:**
- Test: existing contract, read-projection, extension and server suites
- [ ] **Step 1: Add read projection regressions for all three base kinds and reject mismatched supplement kinds.**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "trade-message-center",
"version": "0.8.28",
"version": "0.8.30",
"private": true,
"workspaces": [
"apps/*",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@trade-message-center/onetalk-contract",
"version": "0.8.28",
"version": "0.8.30",
"private": true,
"files": [
"dist"
+4
View File
@@ -112,6 +112,7 @@ export {
createOneTalkMessageCreatedFrame,
createOneTalkMessageUpdatedFrame,
} from "./messages.ts";
export type {
OneTalkObservationSource,
OneTalkDirection,
@@ -225,6 +226,7 @@ export {
isOneTalkSendResultStatus,
isOneTalkSendResultReason,
isOneTalkOutboundContent,
isOneTalkReplyToMessageId,
createOneTalkSendCommandFrame,
createOneTalkSendConfirmationFrame,
createOneTalkSendResultFrame,
@@ -238,7 +240,9 @@ export type {
OneTalkOutboundMediaSource,
OneTalkOutboundImageSource,
OneTalkOutboundContent,
OneTalkTextOutboundContent,
OneTalkJsonValue,
OneTalkSendPayload,
OneTalkSendRequestFrame,
OneTalkSendCommandFrame,
OneTalkSendConfirmationFrame,
+21 -4
View File
@@ -42,6 +42,7 @@ export type OneTalkOutboundContent =
| { kind: "text"; text: string }
| { kind: "image"; source: OneTalkOutboundMediaSource }
| { kind: "file"; source: OneTalkOutboundMediaSource };
export type OneTalkTextOutboundContent = Extract<OneTalkOutboundContent, { kind: "text" }>;
export type OneTalkJsonValue =
| null
| boolean
@@ -60,14 +61,21 @@ export type OneTalkSendConfirmationPayload =
export type OneTalkSendResultPayload =
| { status: "confirmed_sent"; message: OneTalkCenterMessage; reason?: never }
| OneTalkSendRejectedPayload;
export type OneTalkSendPayload =
| { conversationId: string; content: OneTalkOutboundContent; replyToMessageId?: never }
| {
conversationId: string;
content: OneTalkTextOutboundContent;
replyToMessageId: string;
};
export type OneTalkSendRequestFrame = OneTalkBaseFrame<
"send.request",
{ conversationId: string; content: OneTalkOutboundContent },
OneTalkSendPayload,
"mind_page"
>;
export type OneTalkSendCommandFrame = OneTalkBaseFrame<
"send.command",
{ conversationId: string; content: OneTalkOutboundContent },
OneTalkSendPayload,
"plugin"
>;
export type OneTalkSendConfirmationFrame = OneTalkBaseFrame<
@@ -83,6 +91,8 @@ export type OneTalkSendResultFrame = OneTalkBaseFrame<
const isNonEmptyString = (value: unknown): value is string =>
typeof value === "string" && value.trim().length > 0;
const isCanonicalReplyToMessageId = (value: unknown): value is string =>
isNonEmptyString(value) && value.trim() === value && !/^[0-9]+\.PNM$/u.test(value);
const hasExactKeys = (value: Record<string, unknown>, keys: readonly string[]): boolean => {
const actual = Object.keys(value).sort();
const expected = [...keys].sort();
@@ -129,6 +139,10 @@ export const isOneTalkOutboundContent = (value: unknown): value is OneTalkOutbou
);
};
/** 引用目标只能是规范化的 OneTalk 业务消息 ID,不能携带 SDK `.PNM` 传输别名。 */
export const isOneTalkReplyToMessageId = (value: unknown): value is string =>
isCanonicalReplyToMessageId(value);
export const isOneTalkSendResultStatus = (value: unknown): value is OneTalkSendResultStatus =>
typeof value === "string" && ONETALK_SEND_RESULT_STATUSES.some((status) => status === value);
export const isOneTalkSendResultReason = (value: unknown): value is OneTalkSendResultReason =>
@@ -144,9 +158,12 @@ export const isValidOneTalkSendingPayload = (type: string, value: unknown): bool
if (!isPlainRecord(value)) return false;
if (type === "send.request" || type === "send.command")
return (
hasExactKeys(value, ["content", "conversationId"]) &&
isNonEmptyString(value.conversationId) &&
isOneTalkOutboundContent(value.content)
isOneTalkOutboundContent(value.content) &&
(hasExactKeys(value, ["content", "conversationId"]) ||
(hasExactKeys(value, ["content", "conversationId", "replyToMessageId"]) &&
value.content.kind === "text" &&
isCanonicalReplyToMessageId(value.replyToMessageId)))
);
if (type !== "send.confirmation" && type !== "send.result") return false;
return (
+1 -1
View File
@@ -50,7 +50,7 @@ import type {
OneTalkStorageDeleteCommandFrame,
} from "./rebuild.ts";
export const ONETALK_PROTOCOL_VERSION = 8 as const;
export const ONETALK_PROTOCOL_VERSION = 9 as const;
export const ONETALK_FRAME_TYPES = [
"ws.hello",
@@ -2205,6 +2205,65 @@ test("covers anchors, sync completion, acknowledgements, and all send results",
});
assert.equal(command.type, "send.command");
const quoteCommand = decode({
...frameBase,
type: "send.command",
sendRequestId: "send-quote-command",
payload: {
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
replyToMessageId: "123456",
},
});
assert.equal(quoteCommand.type, "send.command");
assert.equal(quoteCommand.payload.replyToMessageId, "123456");
for (const payload of [
{
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
replyToMessageId: "",
},
{
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
replyToMessageId: "123456.PNM",
},
{
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
replyToMessageId: " 123456 ",
},
{
conversationId: "conversation-1",
content: {
kind: "image",
source: {
downloadUrl: "https://mind.example.test/bridge/image-1",
fileName: "image.jpg",
mimeType: "image/jpeg",
},
},
replyToMessageId: "123456",
},
{
conversationId: "conversation-1",
content: { kind: "text", text: "hello" },
replyToMessageId: "123456",
unknown: true,
},
]) {
assert.deepEqual(
decodeOneTalkFrame({
...frameBase,
type: "send.command",
sendRequestId: "send-invalid-quote-command",
payload,
}),
{ ok: false, code: ONETALK_ERROR_CODES.invalidMessage },
);
}
const confirmation = decode({
...frameBase,
type: "send.confirmation",