Skip to content

message: exclude a send echo by platform message id - #12

Merged
std-microblock merged 1 commit into
std-microblock:mainfrom
std-external:fix/own-send-echo
Oct 18, 2026
Merged

std-microblock merged 1 commit into
std-microblock:mainfrom
std-external:fix/own-send-echo

Conversation

@std-external

@std-external std-external commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

问题

crossgram 里发一条普通文本(AA 就够),发送端会先出现「消息 1」,紧接着消息 1 播「消息粉碎」动画,然后再出现「消息 2」。PC(tdesktop 系)和 Android 都能看到这两条:一条是本地乐观消息,一条是平台回显出来的服务端副本,没有合并。

两端对账的入口都只看 random_id → 本地占位:

  • 桌面 tdesktop 7.3.1 api_updates.cpp:1671:updateMessageID 按 random_id 找到本地 item;newId 上已经存在 item 时,两条分支都必须 destroy 一条。被移除的视图会走 thanos_effect_controller.cpp:107 的 captureOnRemoval,就是那个粉碎动画。
  • Android(Nagram)MessagesStorage.java:14008:把占位行的 mid 改成服务端 id,撞主键(副本已在库)就落到 :14021 的 DELETE 分支,占位气泡消失、副本留下。

所以「预设消息 / 回显」没合上,前提是服务端副本比回包先到发送端。

relay 的设计本来保证发送端只收回包:_localDelivery(..., true) 按 auth key 排除本地发布,回包里带 updateMessageID(send-observer-reconciliation.e2e.test.ts 就是这条约定)。多出来的那条副本只可能来自平台(QQNT)对自己刚发出的消息的回显——它走普通事件发布,没有 random_id,也不排除发送端。

旧方案(本 PR 第一版)为什么不好

第一版是「发送 RPC 期间把 auth key 标记为正在发送,期间所有自己发出的消息都不推给它」:

  • 按时间段、按 auth key 粗粒度屏蔽:判断依据是「此刻有没有在发消息」,不是「这条消息是谁发的」。
  • 误杀:同一时间从 QQ 手机端(或另一个客户端)发出的别的消息,会被顺带从发送端屏蔽掉,只能等 difference 补。
  • 漏:回显慢于 finally(平台返回后才回显)、发送重试、离线补推,都落在窗口外;并发/重连时窗口归属也不准。
  • 语义上说不通:发送端的豁免应该属于「这一条消息」,不属于「这一段时间」。

候选方案

方案 做法 trade-off
a. 回执语义(updateShortSentMessage) 发送端只回 id/date/pts,不回消息体 最贴近官方私聊语义,但 crossgram 把 QQ 群投影成 channel,updateShortSentMessage 既没有 peer/from_id 也没有投影后的媒体、按钮、回复;Android 侧当初就是因为这个从 short 回执改成完整 updates(见 be3c62f 前的注释)。会丢掉服务端投影字段,回归面大。
b. 按消息身份精确排除(本 PR 采用) 平台返回发送结果后,把「这些平台消息是当前 auth key 发的」登记成 (platform session, 平台消息 id) → auth key;UpdateManager 发布这条消息时只把该 auth key 从投递里排除 没有时间窗、没有「整段时间屏蔽」;只影响这一条消息,其他消息(含同一时刻从 QQ 手机端发的)照常推送。边界:回显如果早于发送响应,归平台侧原有的 originRequestId 过滤(platform-crossgram 的 own-origin 规则)负责。
c. 直接丢掉自己发送的回显事件 不投递,完全依赖发送 RPC 投递 少一次事件,但发送 RPC 失败时这条消息就进不了 journal,其他设备要等历史刷新才能看到,属于丢数据风险,需要额外的失败补偿路径。不做。
d. 放宽客户端合并条件 两端各自 patch 合并逻辑 两端都要改、要跟着上游版本维护;而且「必须删一条」是客户端语义本身,改不动动画,只是把复杂度挪到最难升级的地方。不做。

选 b:一处判断、按消息身份、失败模式温和(最坏情况只是少推一次,客户端还能靠 difference 拿到)。

实现

  • 新增 packages/bridge/src/send-echoes.ts:SendEchoes 保存 (platformSessionId, 平台消息 id / sourceIds) → authKeyId,bind() / release() / excludeFor()。
  • DialogRpc 新增 _withSendEchoes(sent, publish):在拿到平台发送结果之后、投递本地副本之前登记这批平台消息,投递结束(成功或失败)即解绑。用在 sendMessage(文本)、sendRichContent(媒体/相册)、forwardMessages、系统会话转发。
  • UpdateManager.publish():发布 message / message-edit 时,如果这条消息命中登记,就只给这次投递补 excludeAuthKeyId;其他设备照常收到。
  • 解绑之后到达的同一条回显由既有的事件去重(eventKey 已发布)挡掉,所以不需要长时间持有登记。

边界

  • 回显早于 messages.sendMessage 响应:本机制来不及登记,由平台侧 originRequestId own-origin 过滤覆盖(platform-crossgram/src/index.ts:461、:620)。两者互补,没有重叠判断。
  • 平台回显如果拿不到同一个平台消息 id(极少见,意味着平台把一次发送拆成了 id 不同的消息),只能靠事件去重和 difference 收敛。
  • 平台未回传 auth key(无 _authKeyId)时不登记,行为与改动前一致。

测试

  • packages/bridge/src/send-echoes.test.ts:绑定 / 多段消息 sourceIds / 其他消息与其他 session 不受影响 / 解绑 / 无 auth key。
  • send-observer-reconciliation.e2e.test.ts 新增两条:
    • excludes only the sending auth key for the echoed message, not every own message:绑定后回显只推给另一台设备;同一时刻从别的客户端发出的自己消息仍然两台都收到(直接覆盖旧方案「误杀」的问题);解绑后恢复全量推送。
    • binds the sent platform message for the whole local publish:走真实 messages.sendMessage,断言投递本地副本的那一刻登记已生效、RPC 结束后已解绑。
  • vitest run packages/bridge/src/send-echoes.test.ts packages/bridge/src/dialogs.test.ts packages/bridge/src/update-manager.test.ts packages/bridge/src/session-resolver.test.ts → 120 passed;tsc --noEmit 通过。

CI 状态

  • 我的改动:yarn typecheck(tsc -p tsconfig.test.json)在本分支为 0 错误;send-echoes.test.ts / dialogs.test.ts / update-manager.test.ts / session-resolver.test.ts 120 passed;mtproto e2e 文件里的三条用例全过。上一版的 ReferenceError: activeSends is not defined(createSessionResolver 作用域)已随这版重做消失。
  • 基线红:main(7a1ecfc)的 Unit tests and typecheck 本来就是红的,tsc -p tsconfig.test.json 报 4 个类型错,全部在 platform-crossgram(index.ts:1041 的 refresh、invite-accept.e2e.test.ts 两处、platform.test.ts 的 card mock)。这是加群邀请按钮那次提交(22f7608)带进来的类型漂移,和本 PR 无关;main 的 CI run 39050915894 可以直接看到。
  • 为了不把两件事混进一个 PR,基线修复单独开了 qqnt: fix the baseline typecheck errors on main (no behavior change) #13(qqnt: keep the inline-button refresh flag in the return type),它已经 全绿(unit/typecheck 双平台、webui、compose、publish 全 pass)。
  • 因此本 PR 目前只有 Unit tests and typecheck 两项红,失败日志就是上面那 4 个基线错误(没有测试失败、没有本 PR 文件的错误)。qqnt: fix the baseline typecheck errors on main (no behavior change) #13 合并后 rerun 本 PR 即全绿;其余 job(WebUI e2e / Compose e2e / Publish)本 PR 已经 pass(上一轮 WebUI 的超时是 flake,重跑已过)。

@std-external std-external changed the title message: keep a native send echo away from the sending auth key message: exclude a send echo by platform message id Oct 18, 2026
@std-microblock
std-microblock merged commit ffbbff6 into std-microblock:main Oct 18, 2026
3 of 5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants