feat: 替身模式增强——引用回复/群白名单/Owner控制卡片/头像解析 & 修复多触发回复锚点串扰#445
Conversation
- 后端:save 时拒绝 cross-app open_id,返回 reason 便于 dashboard 提示 - 后端:SubstituteTarget 增加 avatarUrl 字段并持久化 - 后端/dashboard:新增 POST /api/bots/:appId/substitute-targets/resolve 预览接口 - dashboard:target ID 输入框 blur 时自动调用 Lark 解析并回填 name/avatar - dashboard:解析成功后姓名只读,头像与姓名在同一排展示 - dashboard:解析失败时显示红色原因 chip(如 cross_app_open_id) - i18n:补充 substitute 解析相关文案 - 测试:更新 substitute-target-resolve 测试 - gitignore:忽略 Playwright MCP 验证产物
deepcoldy
left a comment
There was a problem hiding this comment.
Review @ head 72bdde87
整体方向和实现质量不错:replyRootId 锚点修复思路正确且只作用于 substitute 分支、控制卡时序(worker-ready 消费在 card-off 早退之前)正确、normalize 向后兼容、测试覆盖比较全。本地验证:pnpm build 绿,PR 涉及的 14 个测试文件 381 个用例全绿(fork PR 无 CI,已本地跑)。
但当前不可合并:落后 master 130 个提交,7 个文件冲突(src/cli.ts、src/core/worker-pool.ts、src/daemon.ts、src/im/lark/event-dispatcher.ts + 3 个测试)。冲突全落在本 PR 的核心改动点上,且 master 侧 event-dispatcher 刚被 #494(群聊 @ 策略统一)改过,rebase 后语义需要重新核对,请 rebase 后我再做 delta review。
P2 — oncall autobound 释放会误伤手动绑定(附带修复 #9)
setWorkingDirMode 退出 oncall 时按 defaultOncallAutoboundChats 成员释放 oncallChats(src/services/oncall-store.ts)。但这个列表的既有语义是「auto-bind 一次性机会已用掉」的 tombstone:
unbindOncall也会把手动解绑的群写进去(防止 auto-bind judge 重新绑定);bindOncall(手动绑定)不会把群从里面移除。
所以它不是「当前由 auto-bind 绑定的群」清单。后果:群 X 曾被 unbind(或曾被 autobind 后释放)→ 用户手动 /oncall bind X 重新绑定 → 之后任何一次 dashboard 把工作目录模式切到 default/off → X 的手动绑定被静默释放。且释放逻辑不检查 defaultOncall 此前是否启用过(mode !== 'oncall' 即触发),触发面比预期宽。
建议:释放条件收紧为「prior?.enabled === true 且 c.workingDir === prior.workingDir(即仍指向旧 oncall 目录)」,或单独维护一份「当前 autobound」列表(autobind 时加、手动 bind/unbind 时移除),不要复用 tombstone。
P3
substituteTriggered是会话级永久闩锁:chat-scope 会话全群共享。regularGroupReplyMode='chat'的 bot 一旦发生替身触发,session.substituteTriggered=true永不回落——之后同群普通 @bot 轮次也永远没有 streaming card / 终端按钮。且daemon.ts:7457的 fold-in 场景(普通会话后来被替身触发)只关卡、不补发控制卡(pendingSubstituteControlCard只在建会话时设置,worker ready 已经过了)。每轮的替身身份其实已有记录(currentReplyTarget.substitute),建议卡片开关改按 per-turn 判定,或至少在 fold-in 时补发一次控制卡。cross_app_open_id误报:resolveSubstituteTargets里getProfile瞬时失败(网络/权限抖动,走 catch)时,手填 open_id 也被判成「属于其他应用」,文案误导;save 路径同理会把此前合法的 open_id target 丢掉(若是唯一 target 则整个保存 400)。建议 catch 分支报resolve_failed,只有 profile 明确返回 null 才报cross_app_open_id。- userId 目标的 resolve 预览 UX:userId passthrough 返回
ok:true但无 name/avatar——用户若还没填名字就失焦,name 输入框会被一个空 chip 替换,无法再手填名字(改 id 才能重置);解析失败(ok:false)时 name 输入框同样消失。建议 chip/badge 只在有内容时替换输入框。 - resolve 双飞竞态:
resolveTargetRow快速两次失焦会有两个 in-flight 请求,last-completion-wins,旧响应可能覆盖新响应;且函数开头从 render 快照targetRows读行数据。低危,建议加个序号守卫。 - i18n key
botDefaults.substituteResolved新增但未被使用。 - 行为变化确认:旧版顶层替身触发的回复是 plain + 引用链;新默认
'thread'会为每次触发在群里开一条话题。对存量替身用户是可见变化(PR 描述已声明)——确认这是有意的默认值即可。 - (非本 PR 引入,记录一下)并发双触发时
currentReplyTarget/quoteTargetId仍是会话单值 last-writer-wins:老 turn 回落 plain 后,quote-chain 可能引到新触发消息(quoteTargetId无 turn 守卫)。本 PR 已显著改善常见路径,残留属既有机制。
核过没问题的
- 控制卡 audience =
resolvedAllowedUsers(ou_ only),与既有私发/card口径一致;token 不落日志。 - quote 模式下 audio / 文件 / 普通 dispatch 三条路径的
replyInThread参数都传对了。 - 白名单 gate 顺序不影响未配置替身的 bot 热路径。
defaultOncallAutoboundChatstombstone 在测试里保持不清空(防重新 autobind)与既有语义一致。
结论
需要先 rebase 解冲突 + 修 P2,P3 里 1/2 建议一并处理。改完请求 delta review 即可,不用整轮重来。
7 个冲突文件的解法: - cli.ts: 保留 master 的 dispatch/voice 重构(uuid/suppressHook/VC managed send), 在新结构上重新应用 replyTargetQuoteOnly 透传 + replyInThread 按 mode 判定 - worker-pool.ts: buildWritableTerminalCard 保留 export(控制卡复用)+ master 的 riff 后端分支;ready handler 中控制卡消费保持在所有 early break 之前 - daemon.ts: sessionReply quote 分支改走 master 新增的 replyWithHookPolicy; substituteTriggered 标记与 codexAppMessageContext、vcMeetingReceiver 块共存 - event-dispatcher.ts: PR 的 replyRootId 独立锚点 + master 的日志防注入 (configuredTargetForLog) 并存 - 3 个测试文件: PR 与 master 各自新增的测试全部保留;cli-send-hook-context 的正则按合并后代码更新(sendTarget.mode === 'thread' + uuid 参数)
master 的 dashboard-rebase-ui-regressions 守门测试禁止自定义下拉嵌在 label 内 (点击标题会误激活第一个选项),与相邻 disclosure 下拉对齐改用 bd-field div。
|
已代为解决与 master 的冲突并推送(
验证(fork PR 无 CI,全部本地跑):
|
- P2 oncall-store: 退出 oncall 释放 autobound 群的条件收紧为「defaultOncall 此前 确实启用 && 群仍指向旧 oncall 目录」。defaultOncallAutoboundChats 是 tombstone (手动 unbind 也写入、手动 bind 不清除),仅凭成员资格无法区分「当前 autobound」 与「手动重绑」,原实现会把手动重绑的群静默释放 - P3 卡片开关: 去掉 session.substituteTriggered 会话级永久闩锁,streaming card 的替身抑制改按当前轮 currentReplyTarget.substitute 判定——chat-scope 会话全群 共享,闩锁会让替身触发一次后同群普通 @bot 轮次永远无卡 - P3 resolve: getProfile 抛错(网络/限流瞬时失败)时手填 open_id 报 resolve_failed 而非 cross_app_open_id,避免误导用户丢弃合法 target;仅确定性 null 才算跨应用 - P3 dashboard: userId 直填(无 openId 可验证)不再显示空「已解析」chip、保留可 编辑的名字输入框;resolve 双飞加 per-row epoch 守卫防旧响应覆盖新结果; 移除未使用的 i18n key substituteResolved
|
Review 的 P2 与主要 P3 已修复并推送( P2 — oncall autobound 释放误伤手动绑定( P3① — substituteTriggered 会话级永久闩锁 → 按轮判定( P3② — cross_app_open_id 误报( P3③④⑤ — dashboard 细节( 验证: 未处理(初审中已注明):P3 残留并发限制(quoteTargetId 无 turn 守卫,PR 前已存在);替身默认 replyMode 从旧「plain+引用链」变为「thread」是 PR 声明的有意行为变化。 |
…、bind 清 tombstone
- P2#1 单槽锚点: Session 新增 replyTargets(turnId → {rootMessageId,quoteOnly,
substitute},cap 32 淘汰最旧)。排队/并发轮次(如双替身触发)各自保留锚点:
daemon resolveSessionReplyTarget 与 botmux send(pickTurnReplyTarget)按
turnId 精确查 map,miss 再退回单槽 currentReplyTarget(旧会话数据兼容)。
卡片 gate 同根因修复:streamingCardDisabled(For) 增加 turnId 参数走
isSubstituteTurn 精确判定,screen_update 传 msg.turnId、turn 反应传
triggerMessageId——A 替身轮执行中 B 普通轮入队不再互相误开/误关卡片。
字段仿 docCommentTargets 只挂 session(updateSession 既有链路持久化),
A/B 两种到达顺序的路由与 gate 测试各两组
- P2#2 生产可达的瞬时/确定性区分: client.ts 新增 getUserProfileStrict 三态
(ok/not_visible/error),确定性码集合 {41050,41012,40001,99992361}(后者
即现有 allowedUsers 诊断用的「open_id 属它应用」码);瞬时错误不再写负缓存
(原实现连网络抖动都缓存 null)。resolveAllowedUsersWithMap 新增 errored
信号(内部吞错后唯一生产可达的瞬时标志)。substitute deps 契约改三态,
dashboard-ipc 注入 strict 版
- P2#3 同目录手动重绑: bindOncall(显式 opt-in)清除 tombstone,release 的
provenance 判定不再把手动重绑当 autobind;dir-match 双条件保留兜底旧数据
|
codex 二审的 3 个 P2 已全部修复并推送( P2#1 — 单槽锚点/gate → per-turn(核心修复)
P2#2 — 生产可达的瞬时/确定性区分
P2#3 — 手动重绑同目录仍被释放 验证: @alexander2618 供参考:以上改动均为 review 修复,功能语义与你的原设计一致(替身会话行为不变,修的是排队/并发轮次的串扰)。 |
…profile 接住 SDK 抛错并按原因分级
- P2-1 card gate 三个漏口:
1) isSubstituteTurn 有 turnId 时 map miss 只在单槽 turnId 相符时才 fallback,
否则 false——rootless 普通轮(无 replyRootId 不留 map 记录)不再继承后到
替身轮的 slot 标志(codex 复现 normalAIsSubstitute:true 已修)
2) ready 消息本就携带 turnId,gate 改传 msg.turnId——替身 A 在 ready 前被
普通 B 覆盖 slot 不再误发初始卡
3) scheduleCardPatch 加可选 turnId,screen_update 路径透传——PATCH 二次 gate
不再退回单槽(screenshot_uploaded 消息无 turnId,维持 fallback)
测试: 真实 begin(undefined,A)→begin(B,sub) 双顺序 + isSubstituteTurn 无 turn
fallback + scheduleCardPatch 双顺序 gate
- P2-2 strict profile:
1) catch 分支用 getLarkErrorCode 提取业务码——SDK(Axios)非 2xx 以异常抛出,
403+99992361 等确定性失败不再误判 transient
2) 按原因分级: cross_app(99992361)/not_visible(41050)/invalid_id(41012,40001)
/error(其余+网络),负缓存按类别存;substitute reason 相应映射
cross_app_open_id/not_visible(新 i18n)/unresolvable/resolve_failed,
「通讯录不可见」不再谎称「属于其他应用」
3) 新增 lark-user-profile-strict.test.ts: Axios throw 形态驱动真实实现
(cross_app/not_visible/invalid_id/transient 不缓存共 7 例)
|
delta review 的 2 个 P2 已修复并推送( P2-1 — card gate 三个漏口全部补上
P2-2 — strict profile 接住 SDK 抛错 + 按原因分级
验证: @codex 请做第三轮 delta( |
|
To use Codex here, create a Codex account and connect to github. |
两个替身特性做并集,语义组合点: - SubstituteModeConfig/dashboard/PUT: deepcoldy#475 topicGroups+topicActiveSessionTrigger 与 deepcoldy#445 chats/replyMode/disableControlCard 全保留 - event-dispatcher 触发门: chats 白名单前置到 getChatMode 之前(纯内存判断走在 API roundtrip 前),对普通群与话题群统一生效;group 分支保留 deepcoldy#445 的独立 replyRootId 锚点,topic 分支维持 deepcoldy#475 的 thread-scope 路由 - owner 控制卡收窄为仅 chat-scope 替身会话发送: 控制卡是为 card-off(avatar) 的群会话补偿的;deepcoldy#475 话题替身会话是 thread-scope、保留正常 streaming card, 不需要也不该多收一张 DM - 测试: git 把 deepcoldy#445 白名单测试与 deepcoldy#475 六个话题群测试错位穿插,按两侧原文 拆开重组,15 个 substituteMode 用例全绿
|
master 合入 #475(替身模式支持话题群)后的语义冲突已解决并推送(
验证: @codex 请审这段 merge delta( |
|
To use Codex here, create a Codex account and connect to github. |
deepcoldy#475 合入后 chats 白名单对话题群也生效、控制卡仅普通群发、replyMode 仅 普通群路径生效——zh/en 六条帮助文案同步精确化,纯文本无逻辑改动。
|
codex 终审的 P3 文案项已顺手修掉( |
概述
优化替身模式(substitute mode),新增回复方式选择、群聊白名单、owner 控制卡片、头像自动补全等能力,并修复了多替身触发时回复锚点串扰的问题。
主要变更
新功能
replyMode):替身触发后可选择「话题回复」(默认,在触发消息下创建 Lark 话题)或「引用回复」(引用触发消息,不创建新话题)。chats):可指定替身模式仅在哪些群生效,留空则所有普通群均可触发。disableControlCard关闭。修复与改进
replyRootId),不再共享/覆盖currentReplyTarget,避免回复串到错误消息下或塌成 plain 发送。isSubstitute)。SubstituteTargetResolution新增reason字段(cross_app_open_id/resolve_failed/unresolvable),手填的跨应用 open_id 现在会被拒绝,避免运行时静默失败。附带修复
setWorkingDirMode退出 oncall 模式时,释放由 defaultOncall 自动绑定的群(回退到 bot 级默认目录),手动绑定的群保留。向后兼容
normalizeSubstituteMode会对非法值进行 coerce(如无效replyMode回退为thread)。chats/replyMode/disableControlCard/avatarUrl)在未设置时不写入持久化配置。测试
test/substitute-control-card.test.ts(控制卡片投递全场景)test/substitute-mode-normalize.test.ts(配置归一化)