From 784bbec2a05cf431a54928fbb12bcf04a77294ae Mon Sep 17 00:00:00 2001 From: YBF <47051132+YBFACC@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:56:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=20OneTalk=20?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E5=BC=95=E7=94=A8=E5=9B=9E=E5=A4=8D=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=20(#66)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../frontend/onetalk/send-sop.md | 19 +- .../check.jsonl | 5 + .../design.md | 265 ++++++++++++++++++ .../implement.jsonl | 5 + .../implement.md | 44 +++ .../09-17-onetalk-quote-reply-protocol/prd.md | 45 +++ .../task.json | 26 ++ .trellis/workspace/ybf/index.md | 5 +- .trellis/workspace/ybf/journal-1.md | 11 + apps/chrome-extension/package.json | 2 +- .../src/onetalk/main-page/collection/entry.ts | 7 +- .../src/onetalk/main-page/commands/index.ts | 10 +- .../src/onetalk/main-page/commands/send.ts | 28 +- .../page-command.ts | 13 +- .../main-page/message-observer/entry.ts | 4 +- .../main-page/message-observer/history.ts | 13 +- .../main-page/message-observer/index.ts | 4 +- .../onetalk/main-page/message-observer/new.ts | 10 +- .../message-observer/quote-source-index.ts | 189 +++++++++++++ .../message-observer/send-observation.ts | 6 +- .../main-page/message-observer/websocket.ts | 4 +- .../src/onetalk/main-page/model.ts | 22 ++ .../onetalk/main-page/page-script-entry.ts | 5 +- .../service-worker/flows/send-command-flow.ts | 3 + .../test/onetalk-quote-source-index.test.js | 198 +++++++++++++ .../test/onetalk-send-command-flow.test.js | 62 ++++ .../test/onetalk-send-page.test.js | 103 +++++++ apps/mind-test-harness/package.json | 2 +- apps/mind-test-harness/src/harness/events.ts | 14 +- .../mind-test-harness/src/harness/messages.ts | 17 +- apps/mind-test-harness/src/harness/page.ts | 4 + apps/mind-test-harness/src/harness/reading.ts | 6 +- apps/mind-test-harness/src/harness/runtime.ts | 4 + apps/mind-test-harness/src/harness/styles.ts | 2 + .../src/harness/websocket.ts | 8 +- apps/server/package.json | 2 +- apps/server/test/onetalk-websocket.test.ts | 12 +- docs/onetalk-quote-reply-mind-integration.md | 136 +++++++++ ...6-onetalk-dom-secondary-card-collection.md | 38 ++- package.json | 2 +- packages/onetalk-contract/package.json | 2 +- packages/onetalk-contract/src/index.ts | 4 + packages/onetalk-contract/src/sending.ts | 25 +- packages/onetalk-contract/src/wire.ts | 2 +- .../onetalk-contract/test/contract.test.ts | 59 ++++ 45 files changed, 1403 insertions(+), 44 deletions(-) create mode 100644 .trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/check.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/design.md create mode 100644 .trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.jsonl create mode 100644 .trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.md create mode 100644 .trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/prd.md create mode 100644 .trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/task.json create mode 100644 apps/chrome-extension/src/onetalk/main-page/message-observer/quote-source-index.ts create mode 100644 apps/chrome-extension/test/onetalk-quote-source-index.test.js create mode 100644 apps/chrome-extension/test/onetalk-send-command-flow.test.js create mode 100644 docs/onetalk-quote-reply-mind-integration.md diff --git a/.trellis/spec/chrome-extension/frontend/onetalk/send-sop.md b/.trellis/spec/chrome-extension/frontend/onetalk/send-sop.md index 9780aaa..c23ecdf 100644 --- a/.trellis/spec/chrome-extension/frontend/onetalk/send-sop.md +++ b/.trellis/spec/chrome-extension/frontend/onetalk/send-sop.md @@ -20,6 +20,16 @@ type OneTalkUiSendInput = { conversationCode: string; content: string; ext?: Record | null; + /** 可选文本引用;必须由 MAIN 内已验证的 raw source 构建。 */ + referMessage?: { + msgId: string; + subType: 1; + senderAliId: string; + senderName: string; + contentAbstract: string; + originalData: Record; + 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 的时刻,仍按正文或媒体指纹确认。 - Base:SDK 返回 { opId } 但观察链尚未闭合,发送合法 delivery_unknown/send_state_lost,不承诺成功。 +- Base:live envelope 缺少 `messageType`,但主 decoder 已得出 received/sent;index 仅据此选择 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_sent;opId/undefined/缺字段/错误方向/缺 reason 均收敛到合法三态。 - 联调 smoke:当前打开非目标会话时发送普通字符串,确认 SDK 由 `cid` 选中目标会话(而不是 SDK.context.cid)、WebSocket sent observation、server inbound confirmation 和随后 send.result;页面出现文本或 SDK 返回 opId 不能作为完成证据。 diff --git a/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/check.jsonl b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/check.jsonl new file mode 100644 index 0000000..352777a --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/check.jsonl @@ -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."} diff --git a/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/design.md b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/design.md new file mode 100644 index 0000000..1681e78 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/design.md @@ -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; + /** 原消息的 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; + 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; + 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` 声称完成。 diff --git a/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.jsonl b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.jsonl new file mode 100644 index 0000000..207496b --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.jsonl @@ -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."} diff --git a/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.md b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.md new file mode 100644 index 0000000..7c2f610 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/implement.md @@ -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 tests:frame writer/parser 的可选字段与拒绝条件、文本行显示回复操作、非文本/系统/optimistic 行不显示、composer 取消和发送 payload、会话切换清理。 + +## Verification gates + +1. Shared contract package tests和 server WebSocket tests(每个后端测试命令必须限制在 60 秒内)。 +2. Chrome extension focused tests:send 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/插件的协议版本;禁止仅回退一端后让新字段被静默吞掉。 diff --git a/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/prd.md b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/prd.md new file mode 100644 index 0000000..1265451 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/prd.md @@ -0,0 +1,45 @@ +# OneTalk 引用回复跨端协议接入 + +## Goal + +让 Mind 用户能选择一条 OneTalk 时间线消息并发送引用回复,同时保持 OneTalk 原始载荷只在插件 MAIN world 内解析。协议必须在 Mind、Center WebSocket、Service Worker 和页面 SDK 间传递确定的引用目标,不让任一层按正文、时间或显示名称猜测引用内容。 + +## Confirmed facts + +- 2026-09-17 的真实 Chromium(OneTalk 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` 或页面渲染不能单独作为投递成功证据。 diff --git a/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/task.json b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/task.json new file mode 100644 index 0000000..95dfc30 --- /dev/null +++ b/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/task.json @@ -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": {} +} \ No newline at end of file diff --git a/.trellis/workspace/ybf/index.md b/.trellis/workspace/ybf/index.md index bb67374..de4bfe3 100644 --- a/.trellis/workspace/ybf/index.md +++ b/.trellis/workspace/ybf/index.md @@ -8,7 +8,7 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 79 +- **Total Sessions**: 80 - **Last Active**: 2026-09-17 @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~1713 | Active | +| `journal-1.md` | ~1724 | Active | --- @@ -29,6 +29,7 @@ | # | 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` | diff --git a/.trellis/workspace/ybf/journal-1.md b/.trellis/workspace/ybf/journal-1.md index 7f0d3c0..debb0bd 100644 --- a/.trellis/workspace/ybf/journal-1.md +++ b/.trellis/workspace/ybf/journal-1.md @@ -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 引用回复跨端协议 + + +**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 diff --git a/apps/chrome-extension/package.json b/apps/chrome-extension/package.json index c4d159f..e6594f7 100644 --- a/apps/chrome-extension/package.json +++ b/apps/chrome-extension/package.json @@ -1,6 +1,6 @@ { "name": "@trade-message-center/chrome-extension", - "version": "0.8.28", + "version": "0.8.30", "private": true, "type": "module", "scripts": { diff --git a/apps/chrome-extension/src/onetalk/main-page/collection/entry.ts b/apps/chrome-extension/src/onetalk/main-page/collection/entry.ts index c2f6ae5..26b7f28 100644 --- a/apps/chrome-extension/src/onetalk/main-page/collection/entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/collection/entry.ts @@ -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)), }); }; diff --git a/apps/chrome-extension/src/onetalk/main-page/commands/index.ts b/apps/chrome-extension/src/onetalk/main-page/commands/index.ts index 8289653..7f4c389 100644 --- a/apps/chrome-extension/src/onetalk/main-page/commands/index.ts +++ b/apps/chrome-extension/src/onetalk/main-page/commands/index.ts @@ -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; + quoteSources?: OneTalkQuoteSourceIndex; observedSink: OneTalkObservedMessageSink; profileObserver: Pick; 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" }; diff --git a/apps/chrome-extension/src/onetalk/main-page/commands/send.ts b/apps/chrome-extension/src/onetalk/main-page/commands/send.ts index 1cd2db6..6c1180f 100644 --- a/apps/chrome-extension/src/onetalk/main-page/commands/send.ts +++ b/apps/chrome-extension/src/onetalk/main-page/commands/send.ts @@ -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 | null | undefined => @@ -16,15 +21,19 @@ export const handleOneTalkSendCommand = async ( pageWindow: OneTalkPageWindow, message: OneTalkPageCommandMessage, sendObservation: Pick, + quoteSources?: OneTalkQuoteSourceIndex, ): Promise => { 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" }; diff --git a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts index 1fd91df..f656eb6 100644 --- a/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts +++ b/apps/chrome-extension/src/onetalk/main-page/current-conversation-history/page-command.ts @@ -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 => { 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; diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts index 0e368fb..921dfbf 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/entry.ts @@ -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); }; diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts index ed11753..ac94c12 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/history.ts @@ -14,9 +14,11 @@ import { type OneTalkMessageObservationResult, type OneTalkParsedMessageBatch, } from "./model.ts"; +import type { OneTalkQuoteSourceIndex } from "./quote-source-index.ts"; type HistoryMessageInput = { message: Record; + quoteSource: Record; 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)); }; diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts index 72b6e42..713ab9e 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/index.ts @@ -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([]); }; diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts index d9f69c0..692b74d 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/new.ts @@ -8,10 +8,12 @@ import { participantIdsFromValue, type OneTalkMessageObservationResult, } from "./model.ts"; +import type { OneTalkQuoteSourceIndex } from "./quote-source-index.ts"; const parseNewMessage = ( conversation: Record, 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; diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/quote-source-index.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/quote-source-index.ts new file mode 100644 index 0000000..85f58ca --- /dev/null +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/quote-source-index.ts @@ -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; + 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(); + 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); + }, + }; +}; diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts index 7e39adf..105eedd 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/send-observation.ts @@ -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; 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((resolve) => { const record: PendingSend = { kind: "text", @@ -221,6 +224,7 @@ export const createSendObservationCorrelator = ( conversationCode: conversationId, content, ...(ext === undefined ? {} : { ext }), + ...(referMessage === undefined ? {} : { referMessage }), }), ) .then((candidate) => { diff --git a/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts b/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts index 16a0195..2b10e6d 100644 --- a/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts +++ b/apps/chrome-extension/src/onetalk/main-page/message-observer/websocket.ts @@ -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 || diff --git a/apps/chrome-extension/src/onetalk/main-page/model.ts b/apps/chrome-extension/src/onetalk/main-page/model.ts index 373bbb1..316f8f5 100644 --- a/apps/chrome-extension/src/onetalk/main-page/model.ts +++ b/apps/chrome-extension/src/onetalk/main-page/model.ts @@ -2,6 +2,28 @@ export type OneTalkAccountId = string | number; +export type OneTalkSdkQuoteReference = { + msgId: string; + subType: 1; + senderAliId: string; + senderName: string; + contentAbstract: string; + originalData: Record; + sendTime?: number; +}; + +export type OneTalkSdkSendInput = { + cid: string; + conversationCode: string; + content: string; + ext?: Record | null; + referMessage?: OneTalkSdkQuoteReference; +}; + +export type OneTalkSdkMessageService = { + sendUIMessages: (input: OneTalkSdkSendInput) => unknown; +}; + export type OneTalkPageWindow = { location: Pick; document?: Pick; diff --git a/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts b/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts index 5fd2b46..40e9a23 100644 --- a/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts +++ b/apps/chrome-extension/src/onetalk/main-page/page-script-entry.ts @@ -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); }; diff --git a/apps/chrome-extension/src/onetalk/service-worker/flows/send-command-flow.ts b/apps/chrome-extension/src/onetalk/service-worker/flows/send-command-flow.ts index 0b2f713..3a8578d 100644 --- a/apps/chrome-extension/src/onetalk/service-worker/flows/send-command-flow.ts +++ b/apps/chrome-extension/src/onetalk/service-worker/flows/send-command-flow.ts @@ -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) => { diff --git a/apps/chrome-extension/test/onetalk-quote-source-index.test.js b/apps/chrome-extension/test/onetalk-quote-source-index.test.js new file mode 100644 index 0000000..ebd9de2 --- /dev/null +++ b/apps/chrome-extension/test/onetalk-quote-source-index.test.js @@ -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", + ); +}); diff --git a/apps/chrome-extension/test/onetalk-send-command-flow.test.js b/apps/chrome-extension/test/onetalk-send-command-flow.test.js new file mode 100644 index 0000000..81b3255 --- /dev/null +++ b/apps/chrome-extension/test/onetalk-send-command-flow.test.js @@ -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", + }); +}); diff --git a/apps/chrome-extension/test/onetalk-send-page.test.js b/apps/chrome-extension/test/onetalk-send-page.test.js index 0ec902c..320ee46 100644 --- a/apps/chrome-extension/test/onetalk-send-page.test.js +++ b/apps/chrome-extension/test/onetalk-send-page.test.js @@ -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), { diff --git a/apps/mind-test-harness/package.json b/apps/mind-test-harness/package.json index e154ac1..bd9e54c 100644 --- a/apps/mind-test-harness/package.json +++ b/apps/mind-test-harness/package.json @@ -1,6 +1,6 @@ { "name": "@trade-message-center/mind-test-harness", - "version": "0.8.28", + "version": "0.8.30", "private": true, "type": "module", "scripts": { diff --git a/apps/mind-test-harness/src/harness/events.ts b/apps/mind-test-harness/src/harness/events.ts index d580ca0..3474282 100644 --- a/apps/mind-test-harness/src/harness/events.ts +++ b/apps/mind-test-harness/src/harness/events.ts @@ -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); diff --git a/apps/mind-test-harness/src/harness/messages.ts b/apps/mind-test-harness/src/harness/messages.ts index 306601b..1025647 100644 --- a/apps/mind-test-harness/src/harness/messages.ts +++ b/apps/mind-test-harness/src/harness/messages.ts @@ -56,6 +56,20 @@ export const harnessMessagesScript = String.raw` const messageKey = ( return '

' + escapeHtml(content.fileName) + '

' + escapeHtml(content.extension + ' · ' + formatSize(content.sizeBytes) + ' · ' + content.downloadState) + '

' + action + '
'; }; + 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 '
  • ' + escapeHtml(message.direction) + ' · ' + escapeHtml(sentAt) + '' + renderContent(message.content) + '
    ' + escapeHtml(normalized) + '
  • '; + const action = '
    '; + return '
  • ' + escapeHtml(message.direction) + ' · ' + escapeHtml(sentAt) + '' + renderContent(message.content) + action + '
    ' + escapeHtml(normalized) + '
  • '; }).join(''); }; diff --git a/apps/mind-test-harness/src/harness/page.ts b/apps/mind-test-harness/src/harness/page.ts index 4cbd62b..c9e084a 100644 --- a/apps/mind-test-harness/src/harness/page.ts +++ b/apps/mind-test-harness/src/harness/page.ts @@ -51,6 +51,10 @@ export const harnessPageMarkup = String.raw`
    +
    diff --git a/apps/mind-test-harness/src/harness/reading.ts b/apps/mind-test-harness/src/harness/reading.ts index 2d8773e..231e0e3 100644 --- a/apps/mind-test-harness/src/harness/reading.ts +++ b/apps/mind-test-harness/src/harness/reading.ts @@ -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; diff --git a/apps/mind-test-harness/src/harness/runtime.ts b/apps/mind-test-harness/src/harness/runtime.ts index fab4e31..6519b64 100644 --- a/apps/mind-test-harness/src/harness/runtime.ts +++ b/apps/mind-test-harness/src/harness/runtime.ts @@ -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'), diff --git a/apps/mind-test-harness/src/harness/styles.ts b/apps/mind-test-harness/src/harness/styles.ts index 377efb6..22c393d 100644 --- a/apps/mind-test-harness/src/harness/styles.ts +++ b/apps/mind-test-harness/src/harness/styles.ts @@ -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; } diff --git a/apps/mind-test-harness/src/harness/websocket.ts b/apps/mind-test-harness/src/harness/websocket.ts index fa77e74..892e6ae 100644 --- a/apps/mind-test-harness/src/harness/websocket.ts +++ b/apps/mind-test-harness/src/harness/websocket.ts @@ -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(); diff --git a/apps/server/package.json b/apps/server/package.json index 99eb872..d05c8c2 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "@trade-message-center/server", - "version": "0.8.28", + "version": "0.8.30", "private": true, "type": "module", "scripts": { diff --git a/apps/server/test/onetalk-websocket.test.ts b/apps/server/test/onetalk-websocket.test.ts index 6cb9e06..4fd5267 100644 --- a/apps/server/test/onetalk-websocket.test.ts +++ b/apps/server/test/onetalk-websocket.test.ts @@ -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", diff --git a/docs/onetalk-quote-reply-mind-integration.md b/docs/onetalk-quote-reply-mind-integration.md new file mode 100644 index 0000000..29d48cd --- /dev/null +++ b/docs/onetalk-quote-reply-mind-integration.md @@ -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 为准,不要让旧客户端向新协议帧附加未知字段。 diff --git a/docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md b/docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md index c79fa7c..d15808a 100644 --- a/docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md +++ b/docs/superpowers/plans/2026-09-16-onetalk-dom-secondary-card-collection.md @@ -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.** diff --git a/package.json b/package.json index 94cc359..d417f2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "trade-message-center", - "version": "0.8.28", + "version": "0.8.30", "private": true, "workspaces": [ "apps/*", diff --git a/packages/onetalk-contract/package.json b/packages/onetalk-contract/package.json index bf0da66..8ad12e9 100644 --- a/packages/onetalk-contract/package.json +++ b/packages/onetalk-contract/package.json @@ -1,6 +1,6 @@ { "name": "@trade-message-center/onetalk-contract", - "version": "0.8.28", + "version": "0.8.30", "private": true, "files": [ "dist" diff --git a/packages/onetalk-contract/src/index.ts b/packages/onetalk-contract/src/index.ts index 4a966da..1b76807 100644 --- a/packages/onetalk-contract/src/index.ts +++ b/packages/onetalk-contract/src/index.ts @@ -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, diff --git a/packages/onetalk-contract/src/sending.ts b/packages/onetalk-contract/src/sending.ts index d97a288..0aaf87e 100644 --- a/packages/onetalk-contract/src/sending.ts +++ b/packages/onetalk-contract/src/sending.ts @@ -42,6 +42,7 @@ export type OneTalkOutboundContent = | { kind: "text"; text: string } | { kind: "image"; source: OneTalkOutboundMediaSource } | { kind: "file"; source: OneTalkOutboundMediaSource }; +export type OneTalkTextOutboundContent = Extract; 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, 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 ( diff --git a/packages/onetalk-contract/src/wire.ts b/packages/onetalk-contract/src/wire.ts index 77ffbd4..43f09eb 100644 --- a/packages/onetalk-contract/src/wire.ts +++ b/packages/onetalk-contract/src/wire.ts @@ -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", diff --git a/packages/onetalk-contract/test/contract.test.ts b/packages/onetalk-contract/test/contract.test.ts index 919b362..5a5ef16 100644 --- a/packages/onetalk-contract/test/contract.test.ts +++ b/packages/onetalk-contract/test/contract.test.ts @@ -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",