Files
trade-message-center/.trellis/tasks/archive/2026-09/09-17-onetalk-quote-reply-protocol/design.md
T
YBF 784bbec2a0 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
2026-09-17 11:56:35 +08:00

14 KiB
Raw Blame History

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 端。sendMessagesendTextMessage 和 DOM 点击都不是扩展的稳定发送边界。

2. 运行时证据与适用范围

在 2026-09-17 的真实 OneTalk PWA 中,运行时别名 _imBaaSSDK 的版本为 5.0.110。读取其已加载 air.js 可确认:

  1. sendUIMessages(input) 读取 input.referMessage
  2. 它产生 extParam.quoteMessage,其中 messageId 来自 referMessage.msgIdmsgType 来自 referMessage.subType
  3. 消息转换器将 ext.quoteMessage 还原为 extInfo.referMessage

已在该真实会话发送一条 SDK 引用回复,并观察到出站文本与 quote-wrapper。该验证只覆盖“当前 selected 会话”的引用序列化和 UI 渲染;它没有验证跨会话路由。跨会话发送必须遵循现有 PWA 出站发送 SOPcid 为权威目标,不允许依赖当前 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 建议 原消息 sendTimeepoch 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 实时客户端 OneTalkBrightSendMessageRequestbuildOneTalkBrightSendRequestFrame 中原样发送可选 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 时必须:

  1. 先用 normalizeOneTalkMessageId 比较 canonical ID;仅在调用 SDK 前恢复 .PNM 传输别名。
  2. 要求索引中的 conversationId 等于 command conversationIdchannelAccountId 等于当前 page account。
  3. 检查 subTypesenderAliId、原始数据和方向对应的展示名;任何字段缺失均拒绝,而不是把引用降级成普通文本。
  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 会把普通命令交给 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 声称完成。