Skip to content

feat(group): 支持普通群负责人设置与跨部署校验 - #1319

Open
yangdm2015 wants to merge 2 commits into
deepcoldy:masterfrom
yangdm2015:codex/chat-manager
Open

feat(group): 支持普通群负责人设置与跨部署校验#1319
yangdm2015 wants to merge 2 commits into
deepcoldy:masterfrom
yangdm2015:codex/chat-manager

Conversation

@yangdm2015

Copy link
Copy Markdown

变更内容

新增普通群负责人命令 @目标机器人 /manager set|clear|status

  • 负责人接收有对话权限的人的普通群顶层未 @ 消息;只点名其他成员时让路,@ALL 不算转交。
  • 设置与取消要求当前真人发送者明确命中目标机器人的管理员名单。没有白名单的开放机器人也不能自动把陌生人当管理员;不回退到已有会话 owner。
  • 群名追加负责人展示名;取消时恢复原名,但保留期间人工修改的群名。
  • 用群描述的应用 ID 标记跨部署协调,本地持久化管理员启用记录。每次负责人免 @ 响应读取最新远端标记;标记消失、换人或读取失败时,不使用旧缓存继续放行。
  • 切换采用“旧负责人 clear 成功 → 新负责人 set”,不强制接管其他机器人的身份。
  • 维护中英文命令文档。

为什么

普通多机器人群需要一个默认接待者,但逐个配置 ambient 无法表达负责人交接。把免 @ 寻址、管理员操作和跨部署标记分开,可以保留现有授权规则,避免因为换负责人而扩大机器人通信权限。

这是轻量的群负责人机制,与 #1307 的项目状态、进度卡和 dispatch/report 编排不是同一个功能。本 PR 不引入项目卡、任务派发、共享模型上下文或非负责人行内回复规则。

影响范围与边界

  • 只改 Lark 真人消息的前置命令处理、普通群寻址,以及保留命令注册;不改 CLI 适配器、worker 生命周期、模型配置或机器人 talk/operate 权限源。
  • 不接管私聊、独立话题或自动管理的会话群;原有 reply-mode 保持生效,设置命令不会创建 CLI 会话。
  • 独立配置的 never/ambient、消息监听仍按原规则生效,clear 只取消负责人机制。
  • 本地记录在数据目录 chat-managers/ 下,文件权限 0600,复用原子写和文件锁。没有本地管理员启用记录,不能仅凭手工写入的远端标记启用。
  • 群描述空间不足时拒绝,不截断人写的说明。修改后读回确认,不把 HTTP/SDK 成功当最终完成。
  • 飞书群描述接口没有跨机器 CAS。本 PR 不承诺并发跨机器抢占的强一致性;应串行 clear/set,再查 status。网络异常后先查状态,不盲目重试。

验证

在独立 macOS checkout 验证,未连接或重启运行中的机器人:

node node_modules/vitest/vitest.mjs run --project unit \
  test/chat-manager-command.test.ts \
  test/chat-manager-routing.test.ts \
  test/chat-manager-operation.test.ts \
  test/mention-mode-command.test.ts \
  test/bot-talk-parity.test.ts \
  test/command-trigger-reserved-commands.test.ts \
  test/event-dispatcher.test.ts
node node_modules/typescript/bin/tsc --noEmit
bun run build
git diff --check
  • 7 个测试文件、391 项通过,其中新增 29 项行为测试。
  • 新行为已做红绿验证:真实消息寻址门、注册的 Lark 消息处理入口、设置/取消/权限/未配置管理员白名单等。
  • 测试使用真实权限判断、本地文件持久化、锁和消息路由;仅替换外部 Lark 传输及会话执行回调,不校验 Prompt/Skill 文案。
  • 类型检查及完整构建通过(Bun 1.4.2),包括域名检查、Dashboard bundle 和制品检查。
  • 尚未完成真实飞书群灰度、真实跨机器交接或 Linux 运行验证。当前跨部署验证是模拟远端标记变化,不作为线上验收证据。

群名效果示例:ProjectProject · Manager → 取消后恢复 Project;期间用户手动重命名则保留用户的新名称。

@deepcoldy

Copy link
Copy Markdown
Owner

你好!这个 PR 的评审群已自动创建:pr 1319 支持普通群负责人设置

由于你暂时不在自动拉群名单里,未能直接把你拉进群。如果方便的话,请把你的 GitHub 账号和飞书信息补进作者名单文档,补好后后续复审会自动拉你进群。

这是自动流程,如有不便请见谅!

@yangdm2015

yangdm2015 commented Sep 8, 2026 via email

Copy link
Copy Markdown
Author

@deepcoldy

Copy link
Copy Markdown
Owner

你好,感谢这个 PR — 设计上有不少值得肯定的地方(先落本地意图再写远端、写后读回确认而不把 code=0 当完成、拒绝截断人写的群描述、跨部署 clear→set 不强行接管、错误码白名单避免把 URL/路径泄进群里)。以下是自动评审的初步意见,最终以维护者审阅为准。

我在最新 origin/master0aba0fddd)上本地 rebase 后复验(零冲突,rebase 产出树与 merge-tree 预测树逐字节相同)。


🔴 建议合并前修复:打红了 2 个主干既有单测(必需 test check 会红)

src/core/passthrough-commands.ts/manager 加进了 DAEMON_COMMANDS,这触发了仓库里两条既有约束:

FAIL test/command-handler.test.ts > DAEMON_COMMANDS set > should have the correct size
  AssertionError: expected 39 to be 38

FAIL test/command-handler.test.ts > handleCommand > /help > lists every fixed Feishu-executable slash command
  AssertionError: Expected /help to mention /manager

对照组证据(同机、同命令、只换代码):

分支 结果
origin/master (0aba0fddd) Tests 320 passed (320) rc=0
本 PR rebase 后 Tests 2 failed | 318 passed (320) rc=1

第二条是仓库刻意维护的契约:test/command-handler.test.ts:2877 遍历 [...DAEMON_COMMANDS, ...] 要求每一条都能在 /help 文案里找到,也就是说进了 DAEMON_COMMANDS 就必须在 /help 里可发现。目前 /manager 只写进了 docs-site,用户在飞书里发 /help 看不到它。

还有一点值得留意:/manager唯一被放进 DAEMON_COMMANDS 的 ingress 拦截命令 —— 同类的 /mention-mode/reply-mode/substitute/introduce/grant/revoke/invite 都不在该集合里。而 handleCommand 的 switch 里没有 case '/manager'(也没有 default:),所以万一有消息在 ingress 拦截之外走到 daemon 路由(我实测的一条:open 模式 bot 下 peer bot 发 @bot /manager set 会绕过 tryHandleManagerCommand 的真人路径,落到 handleThreadReply),/manager 会命中 DAEMON_COMMANDS 分支、先 createSession 建出一个 worker:null 的会话,然后在 switch 里静默无匹配返回。两个副作用:dashboard 里多一个幽灵会话;canTalkDaemonCommands 现在也接受 /manager(实测可配进去),而这条命令的权限其实由它自己的管理员门把守。

两个修法我都实测过

  • (A) 从 DAEMON_COMMANDS 移除 —— 与全部同类命令一致,但会打红你自己新加的用例(normalizePassthroughCommand('/manager') 期望为 null,即防 CLI 透传抢占),所以单独做这一步不够:Tests 1 failed | 348 passed
  • (B) 保留该条目 + 补齐它带来的两个契约 ✅ 推荐 —— 加 help.manager 文案(src/i18n/zh.ts / en.ts)+ 在 command-handler.ts 的 help 列表里加一行 t('help.manager', …) + 把 size 断言改成 39。实测 Tests 349 passed (349) rc=0、tsc --noEmit rc=0。这样 /help 可发现性也顺带解决了。

若走 (B),建议同时考虑给 handleCommand 补一个 case '/manager'(回一句"请在普通群顶层 @ 我并使用 /manager set|clear|status"),把上面那条幽灵会话路径也堵掉。


🟠 非阻断

N1|群名后缀会累积,且 clear 再也恢复不回原名。 changeChatManagerset 分支基于 remote.name 拼后缀,而幂等短路只看远端 marker 在不在。如果有人在飞书 UI 里把群描述里的 [botmux:manager=…] 那行删掉(描述是人可编辑的,PR 本身也支持"保留人工修改"),再 set 一次就会二次追加:

round1 set → "Project · Manager"
round2 set → "Project · Manager · Manager"
round3 set → "Project · Manager · Manager · Manager"
最后 clear → 仍是 "Project · Manager · Manager · Manager"(原名 "Project" 丢了)

原因是第二次 setoriginalName 覆写成了当时已带后缀的名字;clear 的恢复条件 remote.name === local.managedName 命中的是这个被污染的值。对照组:正常 set/clear 循环 3 次名字稳定在 Project,不累积。建议 set 前先剥掉自己的后缀,或在 originalName 已存在时不覆写。

N2|负责人生效后,每条未 @ 的群消息都多一次飞书 API 往返。 isChatManagercheckGroupMessageAccess 里对每条消息实时读群信息、且刻意不做缓存("标记消失、换人或读取失败时不使用旧缓存",这个取舍我理解)。实测:负责人启用时 10 条未 @ 消息 = 10 次远端读;未启用时因本地 claim 先短路只有 1 次。所以成本只落在真正启用负责人的群上,量级可接受,但活跃大群里这是每消息一次串行网络调用(larkGet 未设 timeoutMs),飞书抖动时会拖慢该群所有未 @ 消息的判定。可考虑加一个很短的 TTL(1–3s)或与 getGroupStats 那样的缓存对齐,同时保留失败即 fail-closed 的语义。


已复核通过的部分

  • tsc --noEmit rc=0、bun run build rc=0(含 dashboard bundle 与制品检查)。
  • 新增 29 个用例全绿;我做了 7 枪反向变异(去掉本地 claim 要求 / 去掉显式管理员门 / 去掉 mentionsAnotherMember 让路 / 跳过写后读回 / 去掉话题群拒绝 / 去掉描述长度门 / 去掉重复 marker 拒绝)—— 7/7 全部转红,测试是真承重的。
  • 权限边界复核:open 模式(无 allowlist)下真人陌生人发 /manager set 被正确拒绝(回"只有目标机器人的 owner/allowedUsers 可以设置或取消负责人"),没有回退到会话 owner。
  • 四种 regularGroupReplyMode(chat / new-topic / chat-topic / shared)下负责人免 @ 寻址均按各自路由语义生效。
  • 全量单测:本机负载很高(并发 vitest 进程数百),首轮 PR 侧 93 failed / master 侧 54 failed;把 PR 独有的失败文件单独重跑后从 21 个文件收敛到 3 个,失败原因是 30s 超时、空 TUI、以及我本机 bun 1.4.0≠要求的 1.4.2(这条在 master 侧同样出现 19 次)。除上面那 2 条 command-handler.test.ts 之外,没有发现本 PR 引入的回归 —— 那 2 条在低负载下单文件复跑仍稳定复现,且 master 单文件跑 320/320 全绿。

⚠️ 关于 CI

这个 PR 的 CI 从未真正跑过:run 34194924981conclusion=action_required(fork PR 等维护者放行),status=completed 看起来像跑完了但其实一行没跑,gh pr checkscommits/<sha>/check-runs 都返回空。所以上面那 2 条红是尚未被 CI 发现的 —— 放行后必需的 test check(3 shard AND)会红。


以上是自动评审的初步意见,可能有误判,最终以维护者审阅为准。辛苦了 🙏

@deepcoldy

Copy link
Copy Markdown
Owner

补充:给上面那条阻断项一个更干净的修法(已实测)

复审后我们把修法又推进了一版。之前我推荐的 (B)(保留 DAEMON_COMMANDS 条目 + 补 /help + 把 size 断言改 39)能让两条红变绿,但它留下两个副作用没解决。下面这版 (B′) 四个问题一起解,而且不需要修改任何断言数字

(B′) 做两件事

① 把 /managerDAEMON_COMMANDS 挪进一个新的 INGRESS_RESERVED_COMMANDSsrc/core/passthrough-commands.ts

你加这条的真实诉求是「别让用户用 customPassthroughCommands/manager 抢走」,也就是 normalizePassthroughCommand('/manager') === null —— 这个用一个独立集合就能达到,不必借 DAEMON_COMMANDS

export const INGRESS_RESERVED_COMMANDS = new Set(['/manager']);

// normalizePassthroughCommand 里加一行:
if (INGRESS_RESERVED_COMMANDS.has(normalized)) return null;

这样一次解决四件事:

问题 (B) (B′)
DAEMON_COMMANDS.size 断言红 要改成 39 不用改,仍是 38
/help 遍历契约红 /help 才绿 不再被自动要求(但仍建议加,见 ②)
幽灵会话(见下) 不修 消失
canTalkDaemonCommands 静默无效配置 仍在 消失
你的透传保留用例 绿 原样绿,不用改

幽灵会话是什么/managerDAEMON_COMMANDS 里时,一旦有消息绕过 ingress 拦截走到 daemon 路由,链路是 parseSlashCommandInvocation 命中 → canRunDaemonCommand 过 → /manager 既不在 SESSIONLESS_DAEMON_COMMANDS 也不在 EXISTING_SESSION_ONLY_DAEMON_COMMANDS ⟹ 进 sessionStore.createSession(worker:null) + claimNewDaemonSession → 然后 handleCommand('/manager') 的 switch 里没有 case、也没有 default:,静默返回。结果是 dashboard 里多一条永远不会有 worker 的幽灵会话。daemon.ts/term /card /rename 等多处注释里把这条支路直接称作 phantom session 并逐个特判躲开,/manager 没有特判。实测可达的一条路径:open 模式(无 allowlist)的 bot 下,peer bot 发 @bot /manager set 会绕过 tryHandleManagerCommand(它是真人路径专属,bot 分支在它之前 return)落到 handleThreadReply/manager 出了 DAEMON_COMMANDS 之后这条路径不再被当成 daemon 命令,与 /mention-mode 等同族行为一致。

canTalkDaemonCommands 那条/manager 在集合里时 parseCanTalkDaemonCommandsInput('manager') 会返回 ['/manager'] —— 配置能通过校验写进去,但 daemon 根本没有这个 handler,配了等于没配(静默无效)。移出后该输入被正确丢弃。

② 仍然把 /manager 加进 /help(这点很重要,别省)

/manager 出了 DAEMON_COMMANDS 后测试不再自动要求它出现在 /help,但应该照同族惯例加/grant /revoke /introduce /reply-mode /invite 这些同样是 ingress 拦截的命令,全都在 /help 里,各自进专属分节(heading_grant / heading_config / heading_collab),并且文案自带 @机器人 前缀来表达「必须 @ 机器人」。照抄这个惯例即可:

// src/i18n/zh.ts(en.ts 同理,i18n 必须双语成对)
'help.manager': '@机器人 /manager [status|set|clear] - 普通群负责人:set 设为默认接待者(顶层免 @ 应答)|clear 取消|status 查看',

// src/core/command-handler.ts 的 /help 数组,紧跟 t('help.reply_mode') 之后
t('help.manager', undefined, loc),

并建议把 /manager 加进 test/command-handler.test.ts:2880 那个 fixedFeishuCommands 硬编码列表(/grant /reply-mode 就在里面)—— 否则将来有人删掉 t('help.manager') 不会有测试转红。我验证过这条 pin 是承重的:删掉 help 数组那行,该用例立刻红。

(B′) 实测结果

改动共 5 文件 16 行passthrough-commands.ts +13/-1、command-handler.ts +1、zh.ts +1、en.ts +1、command-handler.test.ts +1):

tsc --noEmit                                          rc=0(零输出)
bun run build                                         rc=0
test/command-handler.test.ts                          320/320 ✅(size 仍 38,未改断言)
你新增的 3 个 chat-manager 文件                        29/29  ✅
+ bot-registry / command-trigger-reserved-commands     合计 495/495 ✅

并逐项确认:DAEMON_COMMANDS.has('/manager')=false、size=38、normalizePassthroughCommand('/manager')=null、parseCustomPassthroughInput('manager, goal')=['/goal']parseCanTalkDaemonCommandsInput('manager')=[]

另外建议本 PR 内一并修 N1(群名后缀累积)

上一条评论里的 N1 有个很小的修法:set 时如果本地 claim 已存在且 remote.name === local.managedName,就用 local.originalName 而不是 remote.name 当拼接基准,同时不要用已带后缀的名字覆写 originalName。这样人手删掉描述里的 marker 再 set 也不会二次追加,原群名也不会丢。

一处更正

上一条评论我说「同族 ingress 命令都在 /help」时把 /substitute 算进去了,这是错的:它有 help.substitute 这个 i18n key,但 /help 数组里没有渲染它(休眠 key)。真正不在 /help 的是 /mention-mode(连 key 都没有)和 /substitute。不影响上面的结论 —— /grant /revoke /introduce /reply-mode /invite 确实都在 /help,「照同族惯例把 /manager 加进 /help」这条建议成立。


以上仍是自动评审的初步意见,最终以维护者审阅为准。(B′) 的改动我只在本地验证,未推送到你的分支。辛苦 🙏

@deepcoldy

Copy link
Copy Markdown
Owner

关于必要性:能否说明为什么不复用已有的 ambient 模式?

维护者在 review 时提了一个产品层面的问题,我这边核实后转达一下 —— 这不是技术缺陷,而是希望你补充说明设计取舍。

仓库里已经有 /mention-mode ambient,而且它已经是按群(per-chat)配置的

@目标bot /mention-mode ambient      # 只对当前群生效

存储在 chatMentionModes[chatId]src/services/chat-reply-mode-store.ts:76,per-chat 覆盖 per-bot 的 regularGroupMentionMode 默认值)。ambient 的语义是「免 @ 应答群消息,但当消息 @ 了别的具体成员时保持安静,@all 不算」。

把它和本 PR 的负责人免 @ 通道逐条对比,两者的放行条件几乎重合:

ambient(已有) 负责人(本 PR)
免 @ 应答顶层群消息
@ 了别人时让路
@all 不算让路
canTalk 约束
按群配置 chatMentionModes[chatId] ✅ 群描述标记
全群互斥(只能有一个) ❌ 各 bot 各存自己的配置,多个 bot 可同时开
交接协议(旧的先 clear)
群名可见谁在值班

也就是说本 PR 相对 ambient 真正新增的是后三项。我实测确认了互斥确实生效(三个 bot 依次 set,第二三个得到 manager_already_set,同时生效的负责人数 = 1;clear 后交接成功、群名从 G · A 变为 G · B)——这个能力 ambient 确实提供不了,因为 ambient 存在各 bot 自己的配置里,天然无法表达「全群唯一」。所以你选「写进飞书群描述」作为跨机共享点,这个方向本身是合理的。

希望你补充的是:能否在 PR 描述里说明,为什么选择新开一套机制(新命令 /manager + 新持久化层 chat-managers/ + 群描述标记),而不是在现有的 mention-mode 体系上扩展(例如给 ambient 加一个「独占」变体,共享点仍可以用群描述)?维护者关心的是为这三项收益引入一套新的跨机状态是否划算

如果有 ambient 无法承载的具体原因(比如交接语义与 mention-mode 的四档模型冲突、或群名展示必须跟状态强绑定),写清楚会很有帮助;这决定的是走「合本 PR」还是「扩展 ambient」,而不是代码质量问题。


顺带重申一下前两条评论的技术结论,方便你一次看全:

  • 🔴 合并前需修/manager 加进 DAEMON_COMMANDS 打红了 2 个主干既有单测(command-handler.test.ts 的 size 断言 + /help 遍历契约)。修法 (B′) 我已实测(改用独立的 INGRESS_RESERVED_COMMANDS 挡透传 + 照 /grant 惯例把 /manager 加进 /help):5 文件 16 行、495/495 绿、tsc rc=0、bun run build rc=0,且不需要改任何断言数字,同时顺带消掉幽灵会话与 canTalkDaemonCommands 静默无效配置。详见上一条评论。
  • 🟠 建议本 PR 内修 N1:群名后缀会累积且原名不可恢复(人在飞书 UI 删掉描述里的 marker 行后再 set 就二次追加)。修法:set 时若本地 claim 已存在且 remote.name === local.managedName,用 local.originalName 作拼接基准,且不要用已带后缀的名字覆写 originalName
  • ⚠️ CI 从未真正跑过:run 34194924981conclusion=action_required(fork PR 等维护者放行),所以上面那 2 条红尚未被 CI 发现。

以上仍是自动评审的初步意见,最终以维护者审阅为准。感谢你的耐心 🙏

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