Skip to content

feat(substitute): 替身模式增加声明式群聊黑名单(excludedChats) - #677

Open
HanpyBin wants to merge 6 commits into
deepcoldy:masterfrom
HanpyBin:feat/substitute-chat-blocklist
Open

feat(substitute): 替身模式增加声明式群聊黑名单(excludedChats)#677
HanpyBin wants to merge 6 commits into
deepcoldy:masterfrom
HanpyBin:feat/substitute-chat-blocklist

Conversation

@HanpyBin

@HanpyBin HanpyBin commented Jul 30, 2026

Copy link
Copy Markdown

改了什么

给替身模式(替身模式 / substitute mode)增加一个 per-bot 的声明式群聊黑名单 excludedChats——列出的群聊里替身永不触发,是现有「生效群聊」白名单(chats)的镜像。

  • deny-wins:一个群同时在白名单和黑名单里时,以黑名单为准(不代答)。
  • 硬关闭:黑名单群里 /substitute on 也无法开启(运行态开关翻不回来)。
  • 零卡片:黑名单群里 @ 替身对象的消息被当作没读到——不代答、不喂入任何会话、不产生任何卡片。
  • 不影响直接 @机器人:黑名单只静音「替身代答」,直接 @ 机器人提问照常应答(含同时 @bot 和替身对象的情况)。
  • 覆盖普通群与话题群,一视同仁。

为什么

此前想让某几个敏感群永不代答,只能靠人逐群手动 /substitute off(运行态、易被误开),无法在配置层一次性声明「除这几个群外全部代答」——白名单表达不了(要枚举所有群),运行态开关也不是策略。

影响面(公共层改动)

  • 动了 im/lark/event-dispatcher.ts 的替身触发门(isSubstituteAllowedChat + 新增 early-return + isSubstituteExcludedChat helper)——这是普通群/话题群共用的路由路径。两种群形态均已覆盖测试。
  • 未触及:直接 @机器人 的路由/应答、其它 CLI、其它后端、disclosure/topicGroups/控制卡等其它替身子能力。
  • 配置侧 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)。
    • 关键回归测试:用「solo 群 + bot 有活跃会话」复现线上 bug 条件,撤掉 early-return 即变红,加回即绿。
  • 飞书 live 实测(pnpm switch:here && pnpm daemon:restart 后)4 项全过:
    1. 黑名单群 @替身对象 → 连卡片都没有(像没读到)
    2. 黑名单群直接 @机器人 → 正常应答
    3. @机器人 且同时 @替身对象 → 正常应答
    4. 非黑名单群 @替身对象 → 代答如常

UI 改动

Dashboard「Bot Defaults」→ 替身模式 区块新增「黑名单群聊」编辑框,与「生效群聊」并列(含中英文 help 文案说明硬关闭 + deny-wins 语义)。

注:建议补一张 dashboard 新编辑框截图(AGENTS.md 要求 UI 类改动附截图)。

UI改动截图如下:
image

@HanpyBin
HanpyBin requested a review from deepcoldy as a code owner July 30, 2026 16:04

@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.

Claude 首审 🟢 无阻塞(对 PR head 8b9ad3a3 装依赖实测)

先说结论:改动逻辑正确、测试有牙、全出站面已枚举、无 correctness/安全 blocker。未经申晗确认不合码,已 @codex 复审。

核心:两层防御「都必要、不冗余」(这是本 PR 的关键设计)

  1. isSubstituteAllowedChat 黑名单短路event-dispatcher.ts:1131)— deny-wins(黑名单赢白名单)+ 命中即让整段替身块短路,连带跳过其后的运行态开关 isSubstituteEnabledForChat → 配置黑名单 = 硬关闭,/substitute on 翻不回来。
  2. 触发门 early-returnevent-dispatcher.ts:2508)— 命中黑名单 + @替身对象 + 未直接 @bot → 直接 return

为什么两层缺一不可:对黑名单群,第①层已让 substituteChatMode/substituteTrigger 恒为 undefined,但消息仍会 fall-through 到通用群消息门。若 bot 在该群有活跃会话 / solo 群(1人1bot)/ mentionMode 放开,relax 分支(:2833ownsSession && 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 消费者就是 isSubstituteAllowedChatexcludedChats 已在该处镜像。
  • 配置 round-trip 无 silent-wipe:normalize / store / IPC PUT / proxy(dashboard.ts:4555 转发 raw body)/ GET 序列化(bot-payload.ts:77dashboard-ipc-server.ts:2607 整对象透传,无 field-drop)/ dashboard 水合,10 处触点一致;禁用配置持久化镜像 chatsnormalize: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-tree vs 最新 origin/master0 冲突

@codex(ou_17f09bc94fb29ee8e7c66416f42d5804) 请独立复审——重点看两层防御是否真的缺一不可、以及替身喂入面有没有我漏掉的第三条 lane。

@chatgpt-codex-connector

Copy link
Copy Markdown

To use Codex here, create a Codex account and connect to github.

@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.

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/mentionMode relax 可继续把它送入 session。

隔离 mutation 结果:

  1. 删除 early-return,单跑 excludedChats drops the @target message entirely...:按预期失败,handleThreadReply 被调用 1 次,substituteTrigger 虽为 undefined 但消息仍被喂入。
  2. 保留 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.substituteTriggerdispatchHumanMessage 落到 handleNewTopichandleThreadReply。后续的 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

ruanbin and others added 6 commits July 31, 2026 10:34
在 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>
@deepcoldy
deepcoldy force-pushed the feat/substitute-chat-blocklist branch from 8b9ad3a to c942928 Compare July 31, 2026 17:37
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