* 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
14 KiB
OneTalk 引用回复跨端协议设计
1. 结论
引用回复的唯一发送入口是 OneTalk BaaS SDK:
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 可确认:
sendUIMessages(input)读取input.referMessage;- 它产生
extParam.quoteMessage,其中messageId来自referMessage.msgId、msgType来自referMessage.subType; - 消息转换器将
ext.quoteMessage还原为extInfo.referMessage。
已在该真实会话发送一条 SDK 引用回复,并观察到出站文本与 quote-wrapper。该验证只覆盖“当前 selected 会话”的引用序列化和 UI 渲染;它没有验证跨会话路由。跨会话发送必须遵循现有 PWA 出站发送 SOP:cid 为权威目标,不允许依赖当前 selected 会话或 URL。
扩展代码应继续通过 window.IcbuIM.IMBaaSSDK.default 取得 SDK,而不依赖当前页面碰巧存在的 _imBaaSSDK 调试别名。
3. SDK 输入契约
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 侧自行拼装的对象。
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。
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。实现时应在同一次解析中提取最小引用源,并保留在页面生命周期内:
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 时必须:
- 先用
normalizeOneTalkMessageId比较 canonical ID;仅在调用 SDK 前恢复.PNM传输别名。 - 要求索引中的
conversationId等于 commandconversationId,channelAccountId等于当前 page account。 - 检查
subType、senderAliId、原始数据和方向对应的展示名;任何字段缺失均拒绝,而不是把引用降级成普通文本。 - 不把此索引发往 ISOLATED、Service Worker、Bright、Mind、日志或 IndexedDB。页面卸载、账号切换或重载时自然失效。
页面引用菜单可展示多种类型,但真实 SDK 端到端发送只验证了文本 subType: 1。第一版将此作为产品边界:MAIN 只索引并解析文本引用源,任何其它类型都在 UI、WS validator 与 MAIN lookup 三处拒绝。
7. Mind 文本回复交互
Mind 的 AlibabaMessageTimeline 对每条满足下列条件的行显示“回复”操作:
message.text.trim()非空;- 该行有非空
message.id(即 OneTalkmessageId); - 该行不是 system、optimistic 或本地发送状态。
点击后,Mind 仅保存 { messageId, from, text } 作为临时 UI state,用 messageId 写入 OneTalkBrightSendMessageRequest.replyToMessageId。composer 显示发送者与截断后的文本摘要,提供取消按钮;切换会话、成功确认或 pre-send rejection 都清空它。Mind 不接收、保存或展示 OneTalk 的 raw originalData,也不尝试在历史列表中重绘远端引用卡。
8. 现有扩展的接入缺口
现有 send.ts 会把普通命令交给 SendObservationCorrelator.execute。该关联器当前生成:
{ cid, conversationCode: cid, content, ext }
它没有 replyToMessageId 或 top-level referMessage,且 SDK 不会读取 ext.referMessage。因此后续实现必须新增一个经过显式校验的引用目标和 MAIN-only 解析器,并沿以下单一路径传递:
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 声称完成。