Skip to content

fix(lark): 隔离普通群用户创建的话题 - #631

Open
hu5h wants to merge 1 commit into
deepcoldy:masterfrom
hu5h:upstream/regular-group-topic-isolation
Open

fix(lark): 隔离普通群用户创建的话题#631
hu5h wants to merge 1 commit into
deepcoldy:masterfrom
hu5h:upstream/regular-group-topic-isolation

Conversation

@hu5h

@hu5h hu5h commented Jul 28, 2026

Copy link
Copy Markdown

变更说明

普通群的群大厅继续使用原有 chat-scope session;用户显式创建的真实 Lark 原生话题(thread_id=omt_*)作为独立上下文边界:

  • 原生话题 seed(有 thread_id、无 root_id)以自身 message_id 创建 thread-scope session。
  • 后续 root_id + thread_id 回复继续以话题根消息为 anchor,复用同一 session。
  • bot 首次从已有原生话题的后续回复接入时,也保持独立 session。
  • 上述行为在 chat、shared、new-topic、chat-topic 四种普通群回复模式下保持一致。
  • synthetic/shared reply alias(非 omt_*)仍保留原有折回群 chat session 的语义。

Review 问题修复

  • 修复 shared 模式下原生话题后续回复被 shared seed helper 折回群大厅、导致一个话题分裂为两个 session 的问题。
  • maybeApplySharedTopicSeed 不再处理已有 root_id + thread_id 的真实 thread reply。
  • 保留话题群 autoStartOnNewTopic、DM p2pMode=chat、shared alias 和 bot-to-bot 权限路径原语义。
  • 同步 /reply-mode 中英文帮助、Dashboard 帮助及 reply-mode store 契约说明。

影响文件

  • src/im/lark/event-dispatcher.ts
  • test/event-dispatcher.test.ts
  • src/services/chat-reply-mode-store.ts
  • src/i18n/zh.ts
  • src/i18n/en.ts
  • src/dashboard/web/i18n.ts

验证记录

  • pnpm vitest run test/event-dispatcher.test.ts:248/248
  • Lark 关联路由、forward-followup、summary 测试:175/175(交叉 reviewer 独立验证)
  • pnpm exec tsc --noEmit:通过
  • pnpm build:通过
  • git diff --check:通过
  • 本地 Lark 实测:通过
  • traecli-devbox2 深度只读交叉 review:APPROVE

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

首审结论:🔴 CHANGES_REQUESTED — 1 个 P1 阻断项(shared 模式 split-brain)

由 Claude 首审。改动本身的核心机制是对的,但引入了一个 shared 模式专属、可复现的会话分裂(split-brain)回归,需修复后再合。

白话:这个 PR 在做什么

飞书「普通群」里,用户手动新建一个原生话题(客户端「消息 → 创建话题」)时,飞书给这条 seed 消息带 thread_id=omt_*没有 root_id。改动前 decideRoutingWithSource 走不到 real-thread 分支(它要求 root_id && thread_id 同时在),于是这条 seed 落到 regularGroupRouting,按普通群模式处理 —— 在 shared/chat 模式下被拍进「群大堂」chat-scope 会话,这个用户显式新建的话题就被吞了。

本 PR 加了一条判据:decideRoutingWithSource 里,在 topic-chat 检查之后,若 thread_id?.startsWith('omt_') 就判为 real-thread、thread-scope、anchor=messageId —— 让原生话题 seed 起自己独立的会话。同时给 maybeApplySharedTopicSeed 加了 independentTopicSeed 开关,当这是一条「thread-only 的原生 seed」(source==='real-thread' && thread_id && !root_id)时跳过 shared 折叠,防止刚判出来的独立 seed 又被折回大堂。放在 topic-chat 检查之后是对的:话题群 seed 仍保留 source=topic-chatautoStartOnNewTopic 语义不变(测试已覆盖)。

🔴 P1:shared 模式下,原生话题里的后续 reply 会逃回群大堂(split-brain)

independentTopicSeed 只匹配 seed(thread-only,无 root_id)。但原生话题里的后续回复root_id + thread_id,independentTopicSeed=false。此时:

  1. maybeFoldMentionedRegularGroupThreadToChatownsThreadSession=true 首行返回 undefined(reply 命中 seed 建的 thread 会话)—— 正确;
  2. 但随后仍无条件进入 maybeApplySharedTopicSeed,该 helper 没有 ownsThreadSession 守卫,independentTopicSeed 又不匹配 reply,于是把 routing 改成 {scope:'chat', anchor:chatId} 并返回 messageId;
  3. chat anchor 上没有 owner → 最终走 handleNewTopic,这条 reply 落到群大堂,而不是 seed 建的独立 thread 会话 → 一个话题,两个会话

实证(隔离树跑 decideRouting/完整 handler,PR 与 master 对拍,同一 stateful seed→登记 owner→reply 序列):

master (6dcca31b):  SEED  → {NT, scope:chat,   anchor:chat-reply-mode}   ← 折进大堂
                    REPLY → {TR, scope:chat,   anchor:chat-reply-mode}   ← 同一会话,一致
PR (345b689):       SEED  → {NT, scope:thread, anchor:msg-native-seed}   ← 独立会话(本 PR 目的✅)
                    REPLY → {NT, scope:chat,   anchor:chat-reply-mode}   ← 逃回大堂 ❌ split-brain

对拍证明这是 PR 引入的回归,不是既存问题:master 上 seed 本就折进大堂,不产生这个独立 owner,所以既存的 maybeApplySharedTopicSeed root+thread 漏洞不可达;PR 让 seed 独立后才把它变成可达的连续性破坏。

blast radius:仅 shared 模式。 chat 模式对拍验证 reply 正确续在 thread 会话(maybeApplySharedTopicSeed 对非 shared 模式提前 return,ownsThreadSession 守卫生效);new-topic/chat-topic 不受影响。

修复方向(已在隔离树验证,零回归)

maybeApplySharedTopicSeed 是话题 seed 逻辑,不该处理已在 thread 里的 reply。在 helper 开头加一条守卫即可:

// A message already inside a native thread (root_id + thread_id) is a reply,
// not a topic seed; folding it would divert an owned native-topic thread
// session into the group lobby (split-brain). Only fold genuine seeds.
if (message?.root_id && message?.thread_id) return undefined;

隔离树上加此 4 行 + 一条 reply-continuity 回归测试:event-dispatcher 全量 241/241 通过(240 既存 + 新增),FIX-PROBE 确认 reply 正确续在 {scope:thread, anchor:msg-native-seed},现有 shared 用例全绿。建议作者顺带补一条完整 handler 级 seed→reply 连续性回归(现有新测试只覆盖 decideRouting 纯函数层与 seed 单点,未覆盖 reply 在完整 dispatch 下的会话归属)。

验证记录(隔离树,钉 PR SHA 345b689)

  • pnpm build ✅ / pnpm exec tsc --noEmit ✅(EXIT 0)
  • pnpm vitest run test/event-dispatcher.test.ts → 240/240 ✅(含 4 条新测试实跑,非 skip)
  • lark 路由 blast-radius 9 文件 373/373 ✅(message-parser / reply-mode-command / relay-target-routing / trigger-session-reply-mode / forward-followup-* / card-prefs-auto-start / grant-gates / summary-command)
  • 全量 non-e2e 套件因本机并发测试负载被 SIGTERM,未完整采样;codex 独立复审已将全量 4 个失败在 master 复现为基线问题

结论:核心思路正确,合并前需堵上 shared 模式 reply 逃逸这个 P1。未获申晗确认前不合码。

@deepcoldy

Copy link
Copy Markdown
Owner

复审收敛 + 补充:模式语义变化的完整实证矩阵(附给申晗的决策点)

Codex 复审与首审收敛:同意 CHANGES_REQUESTED,P1 根因/修复方向一致。Codex 另提的#3(/reply-mode 公开契约变化)我已独立实证确认,并发现它比描述的更广、且与 P1 存在结构性耦合

实证:4 模式 × (seed / owned-reply) 路由矩阵(PR 345b689 vs master 6dcca31 对拍)

原生话题 seed = thread_id=omt_*root_id;reply = root_id + thread_id,owner 登记在 seed 实际建的 anchor 上。

模式 master seed PR seed master reply PR reply 文档契约(zh.ts:219/en.ts:216/store:6-16)
chat {chat, 大堂} {thread, seed} {chat, 大堂} {thread, seed} 「连原生话题也并进这个会话」→ PR 违反
shared/topic {chat, 大堂} {thread, seed} {chat, 大堂} {chat, 大堂} 「复用同一个 group session」→ PR 违反 + P1 split-brain
new-topic {thread, seed} {thread, seed} 不变 ✓
chat-topic {chat, 大堂} {thread, seed} {thread, seed} {thread, seed} 「各自独立会话」→ master 本就 seed/reply 不一致,PR 修好了

三点在首审之上的补充结论

  1. 变化面 = 3/4 模式(chat / shared / chat-topic),不止 chat+shared。 尤其:PR 后 chat 模式对原生话题的行为chat-topic 完全一致 —— 文档里刻意区分的两个模式(chat=并进 / chat-topic=独立)在原生话题上被抹平。

  2. master 的 chat-topic 自身有一个既存 seed/reply 分裂:seed 折进大堂、reply 却走独立 thread(见表)。PR 顺带把它修一致了 —— 所以对 chat-topic 而言 PR 是改善,其文档「各自独立会话」PR 后才真正成立。

  3. ⭐ P1 与语义决策是耦合的,这决定了修复形态:

    • 若申晗确认「用户显式建原生话题应始终压过 chat/shared/chat-topic」(global 语义):当前结构(omt_ 分支放在 regularGroupRouting 之前)正确,需 ① 首审/复审共识的 guard if (message?.root_id && message?.thread_id) return undefined 堵 P1 ② 同步 zh/en /reply-mode 帮助 + store 注释 ③ 补 4 模式矩阵测试。
    • 若应「只让 chat-topic(+new-topic)隔离原生话题」(narrow 语义):修复不是 guard,而是把 omt_ 分支收窄到仅 chat-topic 触发。关键:这样收窄后 P1 自动消失 —— 因为 maybeApplySharedTopicSeed 只在 shared 模式运行(resolveRegularGroupMode !== 'shared' 提前 return),shared 不再产生独立 seed 就没有可被折散的 reply。
    • 补充:chat-topic 模式本就是为「顶层平铺+原生话题独立」设计的。所以真正的产品问题是:是否需要 chat/shared 也复制这个能力,还是引导用户改用 chat-topic?

给申晗的决策点(一句话)

「普通群里用户显式创建的原生 Lark 话题,是否应无视 /reply-mode 配置、始终起独立会话?」

  • 是 → global 语义:guard 修 P1 + 同步中英文档 + 4 模式矩阵测试。
  • 否 → narrow 语义:omt_ 分支收窄到 chat-topic(顺带消灭 P1),chat/shared 维持文档现状。

无论哪条,当前 SHA 都不合码。另按仓库规范:标题/commit/描述需改中文格式(建议标题 fix(lark): 隔离普通群用户创建的话题),补中文影响面 + 实测记录。

(实证:PR/master 双树钉 SHA 隔离跑 event-dispatcher handler 级探针;候选 guard + 连续性回归 241/241 绿。)

@deepcoldy

Copy link
Copy Markdown
Owner

补充实证:codex 的「结构性 reply-as-seed 漏洞」我已确认为既存(独立于本 PR)

Codex 复审指出:root+thread guard 修掉的是 PR 新增的 native-seed→reply 回归路径,但 maybeApplySharedTopicSeed结构性 reply-as-seed 漏洞在 narrow 语义下仍经 /t / 模式切换 / restore-adopt 可达。我同意这个不变量判断,并把它的核心汇聚点跑了实证。

实证(PR 345b689 vs master 6dcca31 对拍,handler 级)

三条入口的共同形状 = 「shared 模式 + bot 已拥有一个 thread-scope 会话 + 收到一条 root+thread reply」。直接构造这个状态:

LATENT routed (PR):     {NT, scope:chat, anchor:大堂, replyRootId:messageId}
LATENT routed (master): {NT, scope:chat, anchor:大堂, replyRootId:messageId}   ← 逐字节相同

归属探针(靠 replyRootId 区分是哪个函数折的:maybeFold 返回 rootId,maybeApplySharedTopicSeed 返回 messageId):

ATTRIB (PR):     replyRootId = messageId  → 折叠者 = maybeApplySharedTopicSeed
ATTRIB (master): replyRootId = messageId  → 折叠者 = maybeApplySharedTopicSeed

结论

  1. 确认既存:PR 与 master 逐字节一致 → 这是 maybeApplySharedTopicSeed 的既存结构性 bug,不是 PR 引入。带 root_id 的 reply 永远进不了 PR 的 omt_ 分支(先被 rootId && threadId 拦到 real-thread),所以 PR 无从影响它。
  2. 归属精确:maybeFoldownsThreadSession=true 正确退出,但控制流仍落进无 owner 守卫的 maybeApplySharedTopicSeed,由它把 owned-thread reply 折回 lobby。
  3. guard 是对的不变量:if (message?.root_id && message?.thread_id) return undefined 同时修掉 PR 新路径这个既存汇聚点 —— seed helper 永不应处理真实 thread reply。所以即便选 narrow 语义,也应保留这个 guard(或至少另开 follow-up 修既存 split-brain)。

诚实的范围边界

我实证的是汇聚点可达(给定 shared 模式下存在 thread owner,reply 必被折回 lobby)。codex 列的三条具体入口是否都能端到端产生这个 owner(尤其 mode-switch 的 stale 会话驱逐、restore/adopt 的 owner 重建),我没有逐条跑到底 —— 这属于既存 bug 的 follow-up 范围,建议作者/申晗定语义方向后单独立项验证。它不改变本 PR 的合码判定。

双审最终建议(收敛)

  • 阻塞判定不变:当前 SHA 不合码。
  • global 语义:omt_* 全局优先 + root+thread guard(必需)+ 同步中英文档 + 4 模式矩阵测试。
  • narrow 语义(codex 与我都略倾向:chat-topic 已专门表达「顶层群会话+原生话题独立」,语义最可预测):omt_* 特判收窄到仅 chat-topic → PR 新增的 shared split-brain 自动消失;仍建议保留 guard 修既存汇聚点,或另开明确 follow-up。
  • 决策点给申晗仍是一句话:「用户显式创建的原生 Lark 话题,是否应无视 /reply-mode、始终独立?」

@deepcoldy

Copy link
Copy Markdown
Owner

双审收敛 —— 验收清单(供作者修复后自检,避免漏项)

Claude 首审 + codex 复审已完全收敛。当前 SHA 345b689 = CHANGES_REQUESTED / BLOCKED,等申晗拍 global / narrow 语义方向,未确认前不改、不合。定向后作者按下列清单修复,双审再复验。

通用(两条路线都必须满足)

  • 保留 root+thread guard(或明确 follow-up):maybeApplySharedTopicSeed 开头加 if (message?.root_id && message?.thread_id) return undefined。这是正确不变量:seed helper 永不应处理真实 thread reply。
  • handler 级回归:shared + 已有 owned thread + root/thread reply 不得进入 shared seed helper,应续在原 thread 会话(断言 handleThreadReply({scope:'thread', anchor:seedMessageId})handleNewTopic 不调用)。
  • human 主路径必须覆盖;foreign-bot 共用同一 helper,最好补对应断言(两处调用点)。
  • 标题 / commit / PR 中文描述 + 影响面按仓库规范补齐(建议标题 fix(lark): 隔离普通群用户创建的话题,附实测记录)。

若选 global(原生话题始终压过 /reply-mode)

  • 4 模式矩阵:原生 seed 在 chat / shared / new-topic / chat-topic 全部为 thread-scope
  • 同步 zh.ts:219 / en.ts:216 /reply-mode 帮助 + chat-reply-mode-store.ts:6-16 注释(chat/shared 不再「并进群会话」)。

若选 narrow(仅 chat-topic + new-topic 隔离原生话题)

  • omt_* 特判收窄到仅 chat-topic 触发(new-topic 本就由 regularGroupRouting 进 thread)。
  • 矩阵:chat / shared seed 保持 chat-scope;chat-topic / new-topic seed 为 thread-scope
  • 仍保留 guard:narrow 消灭的是 PR 新增的 shared native-topic split-brain,但 maybeApplySharedTopicSeed 的既存 reply-as-seed 汇聚点(PR/master 逐字节一致)不受语义收窄影响,guard 用于兜住它。

Follow-up(不阻塞本 PR 产品决策)

  • /t 强制建 thread / mode-switch 切 shared / restore-adopt 三条入口是否都能端到端产生 shared 下的 thread owner,逐条 e2e 验证 —— 归为既存 bug 的 follow-up。但 guard 的通用回归测试应留在本次修复里,不推迟。

验证基线(双审已完成)

  • PR SHA 345b689 隔离对拍:event-dispatcher 240/240 ✅、tsc/build ✅、lark 路由 blast-radius 9 文件 373/373 ✅;候选 guard + 连续性回归 241/241 ✅;全量 4 失败在 master 原样复现 = 环境基线,不归因本 PR。
  • 4 模式矩阵 + LATENT/ATTRIB 实证见上方两条 comment。

@deepcoldy

Copy link
Copy Markdown
Owner

申晗决策 → narrow(遵循 dashboard 配置);PR 非完全多余,但需收窄

申晗判断:「应遵循 dashboard 中的 /reply-mode 配置」= narrow 语义。据此核实 PR 是否必要,结论:不是完全多余,但当前 global 形态过宽,需收窄到只修真实缺口

核实过程(clean master 6dcca31 实证)

关键问题:遵循配置的前提下,chat-topic 模式(其设计契约就是「顶层平铺连续会话;群内原生话题各自独立会话」,store 注释 :13-17)本身在 master 上是否正常?

master 已有 3 条 chat-topic 隔离测试(event-dispatcher.test.ts:3439/3476/…)全绿,但它们用的都是 root_id + thread_id(话题内回复)—— 这命中 mode 判定之前real-thread 分支,本就隔离正确。没有一条覆盖纯话题根 seed(thread-only,无 root_id)。

补跑纯 seed 探针(clean master):

CTSEED (chat-topic, thread-only seed) routed = {TR, scope:chat, anchor:群lobby}

master 上 chat-topic 的纯话题根 seed 被折进群 lobby,违反它自己的文档契约。 这是真实缺口,不是臆想。

因此

  • PR 的价值点 = 修 chat-topic 纯 seed 未隔离(合理,值得留)。
  • PR 的过宽处 = 顺带把 chat / shared 的原生话题也改成独立(这两个模式文档明确说「并进群会话」)—— 遵循配置就不该动它们。且正是这个过宽引入了 shared 模式 P1 split-brain。

narrow 修法(遵循配置,最小充分)

  1. omt_* 特判收窄到仅 chat-topic 触发(new-topic 本就由 regularGroupRouting 进 thread);chat / shared seed 维持 chat-scope = dashboard 配置语义不变。
  2. 这样 shared P1 split-brain 自动消失(maybeApplySharedTopicSeed 只在 shared 运行,shared 不再产生独立 seed)。
  3. 仍保留 root+thread guard 兜住 maybeApplySharedTopicSeed 的既存 reply-as-seed 汇聚点(PR/master 逐字节一致的既存 bug,与语义无关)。
  4. 补 handler 级回归:chat-topic 纯 seed → 独立 thread 会话;shared owned-thread reply → 不进 seed helper。

一句话:PR 修的问题真实存在(chat-topic 纯 seed),但应收窄成「只补 chat-topic」而非「全局改写」;收窄后既符合「遵循 dashboard 配置」,又消灭 shared P1。等作者按此收窄 + 补测 + 改中文规范,双审复验。

@hu5h
hu5h force-pushed the upstream/regular-group-topic-isolation branch from 345b689 to 0d00d45 Compare July 28, 2026 09:39
@hu5h hu5h changed the title fix(lark): isolate regular-group user-created topics fix(lark): 隔离普通群用户创建的话题 Jul 28, 2026
@hu5h

hu5h commented Jul 28, 2026

Copy link
Copy Markdown
Author

已按 review 的 global 验收路线完成修复并更新 PR:

  • 修复 shared 模式 native topic reply split-brain;
  • 真实 omt_* 原生话题在四种 reply mode 下均保持独立,包括 bot 首次从已有话题回复接入;
  • synthetic/shared alias 保持原折叠语义;
  • 补齐 handler 级连续性与四模式矩阵回归,并同步中英文公开契约文案。

最新 commit:0d00d45a
验证:dispatcher 248/248、关联测试 175/175、tsc、build、diff check 均通过;深度只读交叉 review 已 APPROVE。

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