Skip to content

[Research][Bug] Feishu 群聊 @bot 回复被错误发送到私聊 #681

Description

@kxn

背景

收到外部反馈:在部分飞书群聊中 @Codex Remote bot,bot 没有在群里回复,而是把回复发到了私聊通道。该现象在另一些群里又是正常的。反馈者不确定使用的是 master 还是 release/1.8

当前已知约束:

  • 这是别人机器上的问题,当前调研不能依赖本机日志或本机运行态。
  • 需要先用静态代码分析确认是否存在结构性问题,再决定修复方向。
  • 用户明确不接受把真实原因误报成平台行为;需要找到我们自己的设计/状态边界问题。

目标

确认仓库当前实现中,哪些路径可能导致“群消息最终按私聊目标回复”,并形成后续修复所需的根因边界与设计约束。

范围

  • internal/adapter/feishu/gateway/*
  • internal/app/daemon/app_ui.go
  • internal/app/daemon/app_surface_resume_state.go
  • internal/app/daemon/surfaceresume/*
  • internal/core/orchestrator/service_surface.go
  • master / release/1.8 的相关分支差异确认

非目标

  • 不在当前机器上采集或解释别人的运行日志
  • 不先做症状级兜底补丁来掩盖真实原因
  • 不把飞书平台行为当成默认解释,除非代码证据先排除本地结构性问题

完成标准

  • 明确至少一个真实可达的代码路径,解释“群里 @bot 最终发到私聊”的触发条件。
  • 说清楚这是:
    • inbound 本身缺失 chat_id
    • surface 首次 materialize 进入了错误 scope
    • surface 恢复 / 复用沿用了空 ChatID
    • 或其他更深的状态机 / 路由设计问题。
  • 明确哪些场景可以安全自修复,哪些场景必须调整 surface / routing 设计边界。
  • 如进入实现阶段,修复方案不能引入新的 user/chat scope 串线风险。

当前已证实事实

  1. 普通群文本消息的标准 inbound 路径会从事件中读取 message.ChatId / message.ChatType,按 chat scope 建 surface。
  2. daemon 发 UI 事件时不会直接复用原始消息目标,而是读取 surface 当前保存的 ChatID / ActorUserID 重新调用 ResolveReceiveTarget(...) 计算目标。
  3. ResolveReceiveTarget(...) 的规则是:ChatID != "" 则发群;ChatID == "" 则回退到 ActorUserID 私聊。
  4. ensureSurface(...) 只有在 action.ChatID != "" 时才更新 surface.ChatID;因此如果 surface 首次 materialize 时没有拿到 ChatID,后续很多 action 会长期沿用空 ChatID 状态。
  5. surface resume 会把当前 surface 的 ChatID / ActorUserID 原样持久化,并在 daemon 重启后重新 materialize;坏状态可能跨重启存活。
  6. 仓库中存在天然 user-scope 入口,例如 bot menu 明确构造成 p2p/user surface;card action 在查不到消息到 surface 的历史映射时也会 fallback 自行推导 surface。
  7. masterrelease/1.8 在本问题相关的关键路径上目前未发现实质差异,暂未看到“只在某个分支出现”的证据。
  8. 当前代码与测试已经承认一种混合态:user scope surface 也可能携带 ChatID 并被持久化,这意味着“仅补 ChatID”并不天然违反现有数据模型,但存在串线边界。
  9. inbound_lane 的异步文本/图片/文件入口与同步 ParseMessageEvent(...) 一样,都会先按 SurfaceIDForInbound(gatewayID, chatID, chatType, senderUserID) 计算 surface;没有看到队列层把群 surface 改写成 user surface 的逻辑。
  10. group card callback 在有 messageID -> surfaceID 映射时会保留原 chat-scope surface;现有测试明确覆盖了 group card callback 不应回落为 user surface 的情况。
  11. 正常 attach / route transition 通过 instanceClaims / threadClaims 保证实例和线程在 surface 之间互斥持有;未看到“同一实例同时被群 surface 和私聊 surface 正常双持”后再被执行中途抢走的路径。
  12. maybeAutoSteerReply(...) 只会在“当前 action 所在 surface == active remote binding 所在 surface”时工作,不能把群 surface 上的新消息跨 surface 吸到另一个私聊 turn。
  13. 如果问题只是“同一个群 surface 上的 ChatID 暂时为空”,那么该 surface 后续只要再收到一次带 ChatID 的群 inbound,ensureSurface(...) 就会把它补回;因此长期稳定表现为“群里发起、私聊收结果”时,更像是 turn 一开始就绑错了 surface,或恢复出的 surface 本身就是坏的。

深入调研补充(2026-06-05)

  • 目前已经基本排除“普通群文本入口自己把消息改投到私聊 surface”。同步入口 ParseMessageEvent(...) 与异步入口 PlanInboundMessageEvent(...) / QueuedMessageWork.parseAction(...) 的 surface 计算方式一致,都是 group -> chat scope。
  • 目前也基本排除“执行中途被另一条 private surface 抢占”的主路径。claim 互斥决定了这更像是入口阶段就选错 surface,后面的 remote binding 只是在错误 surface 上稳定延续。
  • 因为 deliverUIEventWithContextMode(...) 的实际回发目标完全取决于 event.SurfaceSessionID 当前对应 surface 上保存的 ChatID / ActorUserID,所以只要某次 turn 在开始前已经绑到了 user-scope 或空-ChatID surface,后续结果稳定发私聊是结构上可达的。

当前根因假设

更可能的问题不是“普通群文本消息解析错误”,而是:

  • 某些群场景在最开始就进入了 user-scope / 无 ChatID 的 surface;或
  • surface 恢复 / 复用后保留了 user-scope / 空 ChatID 坏状态;随后 UI 事件按 surface 上下文重新计算目标时,显式降级成私聊。

相对次要、目前证据不足的方向是:

  • remote turn 在 dispatch 前绑定到了与当前 inbound 不一致的 surface;这仍可能发生,但比“入口就选错 surface”更像次级嫌疑。

待调查问题

  1. 哪些具体入口会在群场景下首次 materialize 出无 ChatID 或 user-scope surface:
    • bot menu
    • card action fallback
    • surface resume / 旧状态恢复
    • 其他 daemon ingress / control action 入口
  2. 哪些路径只是“user-scope 但可安全补齐 ChatID”,哪些路径必须重建 / 重路由到 chat-scope surface。
  3. 是否存在安全的自修复策略:
    • 当收到新的群文本消息且当前 surface 缺失 ChatID 时,只补 ChatID
    • 或直接重建/切换到 chat-scope surface
  4. 需要新增哪些日志/断言,才能在反馈者机器上快速区分:
    • inbound 本身就没拿到群 chat_id
    • surface 中途丢了 ChatID
    • 回复目标被错误地按 user-scope 计算
    • dispatch 绑定在 turn 开始前就已经选错了 surface

日志可观测性

当前已有日志基本能区分三大类场景:

  1. inbound 是否一开始就进错 surface
  • surface action: surface=..., chat=..., actor=..., kind=..., message=...
  • 如果群消息进入时 surface= 已经是 feishu:<gateway>:user:<user>,那就是入口阶段选错 surface。
  1. dispatch 绑定是否在 turn 开始前漂移
  • dispatch prepare / dispatch bound / dispatch sent
  • 这些日志会带 surface=,同时 pending= / active= 中也会列出 surfaceSessionID
  • 如果 inbound 是 chat: surface,但 pending/active remote 绑定成了 user: surface,说明是 dispatch 前后绑定漂移。
  1. outbound 是否因为 surface 上下文缺 ChatID 而退化成私聊
  • ui event: surface=..., chat=..., actor=..., kind=...
  • 如果这里 surface= 仍是目标 surface,但 chat= 为空且最终按 actor 发出,则是 surface 自身路由态坏了。

当前日志的不足是:

  • summarizeRemoteStatuses(...) 会打印 surfaceSessionID,但不会直接展开 surface 当前的 ChatID;排查时还需要对照 surface id 命名或同时看 ui event / surface action 日志。

建议范围

  • 先把问题收敛到“首次建错 surface”还是“恢复/复用保留坏状态”两大类。
  • 调研阶段优先找最小可证明根因,不在未锁定根因前设计补丁。
  • 若确认需要修复,优先修 surface 选择 / 状态来源,而不是仅在最终发送前加兜底回退。

实现参考

  • ParseMessageEvent(...) / PlanInboundMessageEvent(...) / SurfaceIDForInbound(...)
  • deliverUIEventWithContextMode(...)
  • ensureSurface(...)
  • materializeSurfaceResumeStateLocked(...) / currentSurfaceResumeEntryLocked(...)
  • surfaceForCardAction(...) 与 card action fallback surface 推导
  • transitionSurfaceRouteCore(...) / instanceClaims / threadClaims

检查参考

  • 对比 master / release/1.8 是否存在相关逻辑分叉
  • 检查 user-scope 入口能否在群场景下错误复用到业务回复链
  • 检查恢复态是否会把空 ChatID 持久化并稳定带回
  • 检查“收到新的群文本消息后补 ChatID”是否会在多群同人场景下串线
  • 检查 dispatch 绑定是否可能在 inbound surface 正确时仍绑定到另一条 surface

相关文档

  • docs/general/remote-surface-state-machine.md
  • docs/general/feishu-card-ui-state-machine.md

涉及文件

  • internal/adapter/feishu/gateway/inbound.go
  • internal/adapter/feishu/gateway/inbound_lane.go
  • internal/adapter/feishu/gateway/routing.go
  • internal/adapter/feishu/gateway_runtime_support.go
  • internal/app/daemon/app_ui.go
  • internal/app/daemon/app_surface_resume_state.go
  • internal/app/daemon/surfaceresume/feishu.go
  • internal/core/orchestrator/service_surface.go
  • internal/core/orchestrator/service_queue.go
  • internal/core/orchestrator/service_queue_binding.go
  • internal/core/orchestrator/service_route_core.go
  • internal/core/orchestrator/service_routing_claims.go
  • internal/core/orchestrator/service_reply_auto_steer.go

收尾参考

  • 若后续进入修复,必须补充能区分 inbound/surface/send-target 三段的日志或测试断言
  • 若修复触及 surface/routing 逻辑,评估是否需要同步相关 state-machine 文档

执行决策

  • 当前不拆子 issue,原因:问题范围仍集中在 Feishu gateway + daemon surface routing + surface resume 三条紧邻链路内,尚未出现需要独立并行推进的第二目标。
  • 当前 issue 暂作为单个调研 worker 单元,不进入实现阶段,先追求调研闭包。
  • 当前 worker 单元:定位 ChatID 缺失/丢失的真实入口,并给出“可安全自修复”和“必须改设计边界”的分界线。
  • verifier:当前阶段不需要独立 verifier;若后续进入代码修复且变更跨 gateway/orchestrator/resume 三层,默认需要 verifier pass 再 close。

当前执行点

已经完成一轮跨 gateway / ingress / orchestrator / resume 的静态排查,排除了普通 group text 入口、自身 inbound lane、group card callback 正常路径、auto-steer reply、以及“执行中途被另一 surface 抢占”的主路径;当前重点收敛到:

  • 某些入口一开始就选成了 user-scope surface
  • 或恢复出的 surface 在当前 turn 开始前已经带着坏路由态

下一步

  • 继续锁定 1~2 个最可疑入口,优先看:
    • surfaceForCardAction(...) 的 fallback 使用条件
    • surface resume 恢复出的 user-scope / 空-ChatID surface 在后续 attach / dispatch 时如何进入业务回复链
  • 基于外部日志制定最小采证清单,要求一次就能区分:入口建错 surface、dispatch 绑错 surface、还是 surface 自身路由态损坏。

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:daemonDaemon server, admin API, and runtime controlarea:feishuFeishu/Lark integration, gateway, projector, or previewarea:runtimeRuntime manager, process lifecycle, and persisted statebugSomething isn't workingprocessingClaimed for active issue handling in the current turnstatus:needs-investigationNeeds technical investigation before implementation can be scoped safely

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions