* 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
13 KiB
OneTalk PWA 出站发送 SOP
1. Scope / Trigger
凡是 TradeBright send.command 需要由 OneTalk 网页代发,都按本 SOP;这是页面桥、Service Worker、OneTalk SDK 和 Bright confirmation 之间的发送边界契约。
当前 PWA 的稳定入口是 sendUIMessages,不是页面上下文中不存在的 sendMessage,也不是在 web PWA 上实测无效的 sendTextMessage({ cid, content })。
页面发送入口为 main-page/commands/send.ts,由 commands/index.ts 按 action 委派;历史 handler 不再承担发送。发送 pending 仍由 message-observer/send-observation.ts 的单份关联器拥有,实时与历史观察经共用 publisher 提供给其 observe。完整组合与归属见 MAIN 流程边界。
Warning
:OneTalk SDK 的
sendUIMessages归一化逻辑按input.cid || SDK.context.cid选择会话。只传conversationCode不会指定目标,会回退到当前打开的对话;因此cid是不可省略的权威路由字段。
2. Signatures
```ts type OneTalkUiSendInput = { /** OneTalk SDK 选择目标会话的权威字段。 / cid: string; /* 兼容字段,必须与 cid 相同;不能替代 cid。 / conversationCode: string; content: string; ext?: Record<string, unknown> | null; /* 可选文本引用;必须由 MAIN 内已验证的 raw source 构建。 */ referMessage?: { msgId: string; subType: 1; senderAliId: string; senderName: string; contentAbstract: string; originalData: Record<string, unknown>; sendTime?: number; }; };
const messageService = window.IcbuIM.IMBaaSSDK.default.getMessageService(); const localAcceptance = await messageService.sendUIMessages(input); ```
- cid 是 send.command 指定的目标 OneTalk 会话代码,必须为非空字符串,并且必须等于 command 的 conversationId;不从 URL conversationId 猜测,也不要求页面当前 selected 会话与目标一致。
- conversationCode 仅为旧适配器/观测代码保留的兼容字段,当前构造时必须与 cid 相同。SDK 选择目标时不以它为准;只传 conversationCode 会触发 SDK.context.cid 回退并可能发错当前对话。
- content 是要发送的普通字符串;插件只接受协议已收窄的字符串正文。
- ext 可以省略或传 null/对象。SDK 会自行补齐 basicMessageInfo、messageDisplayInfo、messageEventInfo 等内部扩展字段;调用方不得手工拼装转换后的消息对象。
- 文本引用仅由可选顶层
referMessage启用,SDK 不读取ext.referMessage。命令边界只允许规范的replyToMessageId;MAIN 必须按当前channelAccountId + conversationId + canonicalMessageId从短生命周期 raw index 解析该对象。数字 message ID 在 SDK 调用前恢复一次.PNM,但.PNM别名不得跨 Mind/Center wire。缺少、跨账号/会话或非文本来源一律rejected_before_send/invalid_request,不得退化为普通发送。 - live
lastMessage.message原始 envelope 可以没有messageType。仅当该字段缺失时,引用索引可使用已通过主 decoder 的observed.direction推导rec/send;显式出现未知类型、或显式类型与观察方向不一致时必须拒绝。不得从 UI 投影、正文或当前会话拼回方向/发送者。 - 第二个布尔参数不属于发送 SOP,必须省略;SDK 内部默认值为 false,传入其它值可能切换本地消息路径。
SDK 会先按 normalized.conversationCode = input.cid || SDK.context.cid 归一化,再转换为 conversationType: 1、msgType: 101、originalData: { text: content } 及内部 ext。这是页面 SDK 的实现细节,不是插件与 Bright 之间可直接复用的 wire payload。
3. Contracts
- Service Worker 只把 send.command 路由到唯一同账号页面;页面 selected conversation 只用于页面身份/同步,不作为 SDK send gate。
- MAIN 只调用一次 sendUIMessages({ cid: conversationId, conversationCode: conversationId, content, ext, referMessage? }),不调用 sendMessage、send 或 sendTextMessage,不执行 DOM/fallback 发送;
cid与conversationCode必须同值。raw 引用源不越过 MAIN 的观察/bridge 边界、不写入 IndexedDB 或日志。 - SDK 调用前登记短生命周期的发送观察关联;现有 OneTalk WebSocket 旁路 observer 直接消费解析后的消息批次,不使用 confirmation waiter 或 window confirmation listener。
- SDK Promise 返回值只表示本地受理(通常含 opId)。只有 WebSocket 旁路观察到目标会话的完整 direction: sent 消息,才能生成 confirmed_sent。
- 观察消息必须匹配 command 的目标 conversationId(观察消息中的 conversationId),并通过完整 OneTalkMessage guard。若 SDK 返回候选 message ID,候选 ID 是主关联键:只按目标会话、
direction: "sent"、完整消息和 ID 匹配确认,不再额外套正文或时间窗口;候选 ID 不匹配时不得退回正文匹配。只有没有候选 ID 时,才按正文或媒体指纹匹配;不得把sentAtMs早于本地登记 pending 的时刻作为拒绝条件。仅拒绝晚于当前观测时刻五秒以上的未来sentAtMs,防止未来时间戳误关联。 - 每个发送各自登记一个 pending。对每条完整 sent observation,只计算它与当前 pending 的匹配集合:唯一匹配时仅确认该项;零匹配时不改变任何 pending;多重匹配时仅将匹配集合结算为 delivery_unknown/send_ambiguous。观测消息不得结束未匹配的 pending。
- 媒体的 target/current 身份检查只属于 native 发送前的不可逆调用围栏;不得传入 observer 作为清理其它 pending 的条件。SDK 异常、WebSocket 断线、旁路事实不完整、观察超时或无法唯一关联时,返回 delivery_unknown 加有限 reason;不得自动重试或伪造消息。
- 发送观察关联器是 MAIN send handler 的必需依赖;必须先登记 pending observation 再调用 SDK。关联器缺失属于 wiring 错误,必须 fail closed,不得直调 SDK 并立即返回 unknown 作为 fallback。
4. Validation & Error Matrix
| 条件 | 行为 |
|---|---|
| 同账号没有可用页面 | rejected_before_send/waiting_for_page |
| 同账号页面多于一个 | rejected_before_send/ambiguous_page_route,不广播 |
| 页面当前没有 selected 会话 | 仍可向唯一同账号页面投递 SDK send |
| 页面 selected 会话不是目标 | 仍可向唯一同账号页面投递;目标由 SDK input 的 cid 指定 |
| SDK、getMessageService 或 sendUIMessages 缺失 | rejected_before_send/send_not_supported |
| command conversationId 为空、无法生成非空 cid,或 content 非字符串 | rejected_before_send/invalid_request,不调用 SDK |
replyToMessageId 为空、.PNM 别名、跨账号/会话、未加载、非文本或 raw 引用字段不完整 |
rejected_before_send/invalid_request,不调用 SDK,也不降级普通文本 |
| live raw message 缺少 messageType,但其方向已由主 decoder 验证 | 仅使用该 direction 建立 MAIN-only 引用源;发送者、名称、originalData 或时间缺失仍拒绝 |
| live raw message 显式 messageType 未知或与已观察 direction 冲突 | 不登记引用源;后续引用请求 rejected_before_send/invalid_request |
| SDK input 只有 conversationCode、缺少 cid | 禁止调用;若漏过边界,SDK 会按 SDK.context.cid 回退到当前打开会话,属于可导致错聊的错误 |
| SDK 调用抛错 | delivery_unknown/send_connection_lost |
| 仅返回 opId/undefined,没有匹配旁路事实 | delivery_unknown/send_state_lost |
| 有候选 message ID 且 ID 匹配,但消息超出正文时间窗口 | 仍可按 ID 确认;时间窗口不适用于候选 ID 关联 |
| 有候选 message ID 不匹配,但正文相同 | 不确认,不退回正文匹配,最终 delivery_unknown/send_state_lost |
无候选 ID 的完整匹配消息,其 sentAtMs 早于本地登记 pending 的时刻 |
仍确认;本地登记时刻不是关联条件 |
无候选 ID 的完整匹配消息,其 sentAtMs 晚于当前观测时刻五秒以上 |
不确认,防止未来时间戳误关联 |
| 观察消息错误会话、错误方向或缺必填字段 | 不确认,最终 delivery_unknown/send_state_lost |
| 一条完整 sent observation 仅匹配一个 pending,另有其它 pending 等待 | 只确认匹配项;其它 pending 保持等待 |
| 同一条 observation 匹配多个 pending,另有不匹配 pending 等待 | 仅匹配集合为 delivery_unknown/send_ambiguous;不匹配项保持等待 |
| 媒体 target/current 已失效,但该 observation 不匹配其 pending | 不由 observer 提前结算;该 pending 仍只由自己的终态事件结算 |
| MAIN 未注入发送观察关联器 | wiring 失败并 fail closed;不得调用 SDK 直返 unknown |
| 页面结果带非法 status/reason/message 组合 | adapter 收敛为 delivery_unknown/send_state_lost |
5. Good / Base / Bad Cases
- Good:唯一同账号页面接收 command,MAIN 调用一次 sendUIMessages,WebSocket 旁路观察到目标会话完整 sent message,再发合法 send.confirmation。
- Good:当前打开 conversation-A、目标为 conversation-B;不改变页面 selected 状态,SDK input 使用
cid=B(并保留同值conversationCode=B)。 - Good:SDK 未返回候选 ID 时,旁路观察的完整目标消息即使
sentAtMs早于本地登记 pending 的时刻,仍按正文或媒体指纹确认。 - Base:SDK 返回 { opId } 但观察链尚未闭合,发送合法 delivery_unknown/send_state_lost,不承诺成功。
- Base:live envelope 缺少
messageType,但主 decoder 已得出 received/sent;index 仅据此选择 contact/owner 展示名,仍要求完整 raw 引用字段。 - Bad:只传
conversationCode=B、把 URL conversationId 当 SPA 会话、调用旧 API、把 opId 当 confirmed_sent、用页面文本当成功证据或自动重试不确定发送。
6. Tests Required
- Service Worker:发送只要求唯一同账号页面;当前 selected 为其它会话、零 selected、多 selected 时仍投递;同账号多页面不广播;历史 sync 仍保持精确路由。
- MAIN handler:断言只调用一次 sendUIMessages,参数包含
cid、同值的兼容conversationCode、字符串 content 和可选 ext;不调用旧 API、DOM 或 fallback;缺少 cid 的输入不得进入 SDK。 - 引用 source index:覆盖 history 以及没有
messageType的 live envelope;只有经主 decoder 验证的方向可补足缺失类型,显式未知/方向冲突、缺 sender/name/originalData 或跨账号/会话均不得登记,且 raw source 不得进入 bridge/日志/IndexedDB。 - WebSocket observer/correlator:覆盖完整 sent、错误会话、received、不完整消息、候选 ID 匹配(含超出正文时间窗口)/不匹配(不得正文回退)、无候选 ID 的正文或媒体指纹匹配、早于本地 pending 登记时刻的
sentAtMs仍确认、晚于当前观测时刻五秒以上的sentAtMs拒绝、legacysentAt、超时,以及 text/image/file pending 的唯一匹配仅结算自身、多个命中只结算匹配集合、未命中项继续等待;断言缺失 correlator wiring 不会直调 SDK。 - Service Worker/contract:只接受完整 sent message 作为 confirmed_sent;opId/undefined/缺字段/错误方向/缺 reason 均收敛到合法三态。
- 联调 smoke:当前打开非目标会话时发送普通字符串,确认 SDK 由
cid选中目标会话(而不是 SDK.context.cid)、WebSocket sent observation、server inbound confirmation 和随后 send.result;页面出现文本或 SDK 返回 opId 不能作为完成证据。
7. Wrong vs Correct
```ts // Wrong:要求 selected 会话先等于目标,或将 SDK 返回对象当成消息事实。 if (selectedConversationId !== conversationCode) return rejected; const result = await messageService.sendMessage({ conversationId, content }); return { status: "confirmed_sent", message: result }; ```
```ts // Correct:目标由 cid 指定,conversationCode 只保留为同值兼容字段;成功由 WebSocket 旁路完整 sent fact 证明。 const accepted = await messageService.sendUIMessages({ cid: conversationId, conversationCode: conversationId, content, ext, }); void accepted; // 有候选 ID 时只按 ID 关联;无候选 ID 时按正文或媒体指纹关联。 // 不用本地 pending 登记时刻拒绝较早的 sentAtMs;仅拒绝超过当前观测时刻五秒的未来值。 // 未注入 observer correlator 时应 fail closed,不能执行上面的 SDK 调用。 // 由现有 WebSocket observer 匹配完整 direction: "sent" 消息;否则 delivery_unknown。 ```