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