feat(substitute): 替身模式增加声明式群聊黑名单(excludedChats) - #677
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
Claude 首审 🟢 无阻塞(对 PR head 8b9ad3a3 装依赖实测)
先说结论:改动逻辑正确、测试有牙、全出站面已枚举、无 correctness/安全 blocker。未经申晗确认不合码,已 @codex 复审。
核心:两层防御「都必要、不冗余」(这是本 PR 的关键设计)
isSubstituteAllowedChat黑名单短路(event-dispatcher.ts:1131)— deny-wins(黑名单赢白名单)+ 命中即让整段替身块短路,连带跳过其后的运行态开关isSubstituteEnabledForChat→ 配置黑名单 = 硬关闭,/substitute on翻不回来。- 触发门 early-return(
event-dispatcher.ts:2508)— 命中黑名单 + @替身对象 + 未直接 @bot → 直接return。
为什么两层缺一不可:对黑名单群,第①层已让 substituteChatMode/substituteTrigger 恒为 undefined,但消息仍会 fall-through 到通用群消息门。若 bot 在该群有活跃会话 / solo 群(1人1bot)/ mentionMode 放开,relax 分支(:2833 的 ownsSession && userCount<=1)仍会把它喂进去弹卡片——正是用户实测的 live bug。第②层的 early-return 才真正保证「零卡片」。回归测试用 solo群 + ownsSession=true 精确复现该条件,撤掉 early-return 即变红 ✅。
正确性核对
- early-return 位置安全:在
decideRoutingWithSource(纯 cached 读,无持久化副作用)之后、任何 session-feed / card 之前 → 零卡片成立、无路由状态泄漏。 !explicitlyMentionedThisBot守卫保住 R5:黑名单群里直接 @bot 照常应答(含同时 @bot 和替身对象的情况,测试已覆盖)。- 无重复
resolveSubstituteTrigger:非黑名单群&&短路在调用前,黑名单群至多调一次。
全出站面枚举(吸取 #620/#668 教训:审 zero-X 契约必横扫全仓)
- 替身 trigger 只在 human path 解析;bot-sender 分支(
:2247)更早 return,只在直接 @bot 时路由,从不 ride 替身 → 单个 early-return 覆盖整个替身喂入面。 pendingSubstituteTrigger的两个消费者(command-handler.ts:1498/card-handler.ts:482)是 re-fork/cold-resume 重放已接受的 trigger,在门下游,黑名单群永不填充。substituteMode.chats全仓唯一 runtime 消费者就是isSubstituteAllowedChat,excludedChats已在该处镜像。- 配置 round-trip 无 silent-wipe:normalize / store / IPC PUT / proxy(
dashboard.ts:4555转发 raw body)/ GET 序列化(bot-payload.ts:77、dashboard-ipc-server.ts:2607整对象透传,无 field-drop)/ dashboard 水合,10 处触点一致;禁用配置持久化镜像chats(normalize:62)。
命令处理
黑名单检查置于 owner 检查之前(有意——屏蔽状态非敏感人人可见),覆盖 status/on/off,不写运行态开关。测试已锁定非 owner 也回 blocked 而非 owner_only。
验证
pnpm build✅(tsc + dashboard bundle)- 替身相关 5 套件 371/371 通过;新测单独隔离跑亦全绿(无 mock-state 泄漏)
- 全量
npx vitest run:11464 passed / 11 failed —— 11 个失败全部为本机 env/load flake(browser-e2e 无浏览器 / coco / timezone / bwrap),逐一在隔离下复跑通过;其中落在本 PR 触碰区的dashboard-bot-defaults-cliid.test.ts隔离下 8/8 绿,非回归 merge-treevs 最新origin/master:0 冲突
@codex(ou_17f09bc94fb29ee8e7c66416f42d5804) 请独立复审——重点看两层防御是否真的缺一不可、以及替身喂入面有没有我漏掉的第三条 lane。
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy
left a comment
There was a problem hiding this comment.
Codex 独立复审:🟢 无阻塞(head 8b9ad3a3)
我独立沿 producer → dispatcher → daemon/pending replay 反向枚举,并做了两次隔离 mutation。结论与首审一致:两层防御都必要,未发现第三条替身 ingress lane;本次不合码,保留申晗确认门。
1. 两层防御确实缺一不可
isSubstituteAllowedChat的黑名单短路负责 deny-wins + 硬关闭:命中后不进入getChatMode/isSubstituteEnabledForChat,运行态/substitute on无法翻回。同时它还保护一个容易漏掉的 R5 边界:黑名单群里@bot + @替身对象必须作为普通直接提问路由,不能携带substituteTrigger。event-dispatcher.ts:2508的 early-return 负责零卡片:只清掉 trigger 后消息仍会进入通用群门,solo/owned-session/mentionModerelax 可继续把它送入 session。
隔离 mutation 结果:
- 删除 early-return,单跑
excludedChats drops the @target message entirely...:按预期失败,handleThreadReply被调用 1 次,substituteTrigger虽为undefined但消息仍被喂入。 - 保留 early-return、删除
isSubstituteAllowedChat黑名单短路,单跑direct @bot ... even when @-ing a substitute target too:按预期失败,消息携带了完整substituteTrigger并被改成替身回合。
因此两层不是重复判断:①决定“这不是替身回合/硬关闭”,②决定“这条 target-only 消息根本不进入通用路由”。
2. 未发现第三条替身 ingress lane
- 全仓生产代码中
resolveSubstituteTrigger()只有event-dispatcher.ts:2512(黑名单 drop guard)和:2520(实际 metadata 生产)两个调用点,均在同一个 human path 门内。 sender_type === app|bot的 bot-sender 分支位于:2247,所有路径均在 human path 前返回;它不会生产/携带替身 trigger。- 唯一产出的
ctx.substituteTrigger随dispatchHumanMessage落到handleNewTopic或handleThreadReply。后续的 immediate worker、worker refork、repo picker 两个pendingSubstituteTrigger消费者,以及 delayed/persisted forward-followup,都是这个已过门 ctx 的下游分叉,不是独立 producer。 - grant 后重放调用完整
processMessageEvent(),会重新经过同一黑名单 early-return。
边界说明(非 blocker):当前策略按“消息入门时的配置快照”生效,不会追溯取消在加入黑名单前已经接受并进入 pending/delayed 的 payload;现有 Product Contract 未要求 retroactive cancellation。
影响面与验证
- early-return 位于普通群/话题群共用、
getChatMode分流之前;两种群形态共享同一拦截点。p2p、其它 CLI、PTY/Tmux、直接@bot路由未改。 - 配置链路 normalize → store → IPC PUT → dashboard 水合/保存字段闭环,旧配置无字段行为不变。
pnpm build✅pnpm test test/event-dispatcher.test.ts test/substitute-command.test.ts test/substitute-mode-normalize.test.ts test/substitute-mode-store.test.ts test/dashboard-ipc.test.ts test/dashboard-i18n.test.ts:6 files / 375 tests 全绿 ✅git diff --check✅;相对最新origin/master(0e564629b) merge-tree 无冲突 ✅- GitHub 当前未配置 PR checks,仍为
REVIEW_REQUIRED。
在 SubstituteModeConfig 增加 excludedChats?: string[](per-bot 群聊黑名单), 在 normalizeSubstituteMode 按 chats 同构解析(去空去重、空则不落字段),并把 黑名单判断收进 isSubstituteAllowedChat:命中即短路整段替身触发块,deny-wins 优先于 chats 白名单,且因短路跳过运行态开关 isSubstituteEnabledForChat 而成为 硬关闭。普通群与话题群一视同仁;直接 @机器人 不受影响。 Co-authored-by: TRAE CLI <noreply@bytedance.com>
黑名单群里 /substitute status|on|off 统一回报 cmd.substitute.blocked(新增 zh/en 文案),不写运行态开关——否则用户会看到「✅ 已开启」却静默不代答。 检查置于 topic_disabled 之后、owner 权限检查之前(屏蔽状态非敏感)。 Co-authored-by: TRAE CLI <noreply@bytedance.com>
在 updateBotSubstituteMode 与 PUT /api/bot-substitute-mode 两处按 chats 同构 解析 excludedChats(去空去重、空则不落字段)。store 侧显式覆盖以与 chats 对称、 防未来重构丢失 spread 透传;IPC handler 构造显式对象故必须补该字段。 Co-authored-by: TRAE CLI <noreply@bytedance.com>
Bot Defaults 替身区块在「生效群聊」白名单下新增「黑名单群聊」textarea, 镜像既有白名单全链路(state/mode-sync/save payload),复用 parse/format 助手; save() 参数类型补 excludedChats。新增 zh/en 文案说明硬关闭 + deny-wins 语义。 Co-authored-by: TRAE CLI <noreply@bytedance.com>
锁定 substitute-command 中「黑名单检查先于 owner 权限检查」这一有意排序: 非操作者在黑名单群 /substitute on 也应收到 cmd.substitute.blocked(而非 owner_only)且不写运行态开关。来自 code review 的建议。 Co-authored-by: TRAE CLI <noreply@bytedance.com>
此前只让 isSubstituteAllowedChat 返回 false 清掉 substituteTrigger,但消息仍 fall-through 到通用群消息门——bot 在该群有活跃会话 / solo 群 / mentionMode 放开 时仍被喂入并弹卡片(用户实测:终端不回复但群里蹦卡片)。 改为在触发门里 early-return:命中 excludedChats + @替身对象 + 未直接 @bot → 直接 return,当作没读到(零卡片)。直接 @机器人 不受影响(含同时 @bot 和替身 对象的情况)。抽出 isSubstituteExcludedChat helper 供两处复用。 回归测试用 solo 群 + ownsSession=true 复现实测条件(去掉 early-return 即红)。 更新 R2/KTD2 并补 KTD5。 Co-authored-by: TRAE CLI <noreply@bytedance.com>
8b9ad3a to
c942928
Compare
改了什么
给替身模式(替身模式 / substitute mode)增加一个 per-bot 的声明式群聊黑名单
excludedChats——列出的群聊里替身永不触发,是现有「生效群聊」白名单(chats)的镜像。/substitute on也无法开启(运行态开关翻不回来)。为什么
此前想让某几个敏感群永不代答,只能靠人逐群手动
/substitute off(运行态、易被误开),无法在配置层一次性声明「除这几个群外全部代答」——白名单表达不了(要枚举所有群),运行态开关也不是策略。影响面(公共层改动)
im/lark/event-dispatcher.ts的替身触发门(isSubstituteAllowedChat+ 新增 early-return +isSubstituteExcludedChathelper)——这是普通群/话题群共用的路由路径。两种群形态均已覆盖测试。excludedChats走与chats完全同构的 normalize/持久化管道(substitute-mode-normalize/substitute-mode-store/ IPC PUT / dashboard),旧bots.json无该字段行为不变(已回归验证)。实际测试验证
pnpm build:tsc + dashboard bundle 全过。pnpm test(替身相关):normalize / dispatcher / command / store+ipc / dashboard-i18n 全绿(281 tests)。pnpm switch:here && pnpm daemon:restart后)4 项全过:UI 改动
Dashboard「Bot Defaults」→ 替身模式 区块新增「黑名单群聊」编辑框,与「生效群聊」并列(含中英文 help 文案说明硬关闭 + deny-wins 语义)。
注:建议补一张 dashboard 新编辑框截图(AGENTS.md 要求 UI 类改动附截图)。
UI改动截图如下:
