fix(grant): 授权通知卡 bot 有名字用名字、无名字@兜底#422
Conversation
deepcoldy
left a comment
There was a problem hiding this comment.
Review 结论:无阻塞问题,逻辑正确,可合并 ✅
核心机制(真实 @ 被授权 bot + 零宽 sentinel 在路由层吞掉纯通知)成立,改动面收敛在飞书 im/lark 授权通知 + bot 路由,不碰 CLI/后端/其它 IM。建议合并前做 1 处 cheap 加固 + 1 处 live 确认(见下),其余为提示/nit。
我实际跑过的验证
tsc -p . --noEmit→ exit 0(全量类型检查绿,非仅 vitest transform)- 定向测试
grant-card / card-handler-grant / event-dispatcher / message-parser→ 287 passed - 两个新增 dispatcher 测试各自单跑(防套件顺序 mock 泄漏)→ 均绿
- 直接验证 sentinel 存活:
buildGrantNotifyCard→ 模拟resolveEventCard({...structured, __botmux_card_text__: text})→ 结构化 spread 与提取文本双双含 sentinel(四码点无误),任一路径丢失另一路径兜底。
三个重点回答
1)零宽 sentinel 在 interactive 简化 / REST 完整卡路径可靠性 — 机制正确,唯一风险在 Lark 保真
- 简化 WS payload(
{"text":"请升级…"})不含 sentinel → 必走resolveNonsupportMessage → resolveEventCard,把 REST 合并卡写回message.content;此时...structured(= Format B 原始卡 JSON,elements[0].text.content带 sentinel)与RESOLVED_TEXT_KEY文本都带 sentinel,JSON.stringify原样输出零宽字符,第二次.includes命中 → 吞掉。✔ - 若 WS 直接投完整卡(同租户简单卡),第一次
.includes就命中、跳过 REST,更省。✔ - 唯一 live 待确认:
im.message.get(Format A 服务端渲染 / Format B 结构化)是否原样回传 U+200B/U+2063。Format B 优先且是结构化原文,正常应保真;风险组合是「Format B 不可用 + Format A 剥掉零宽」(跨租户 / REST 抖动)→ 不吞 → 若 sender 已 vetted 则重现空会话。PR 截图证明了@展示,但没覆盖「吞掉」这一步,建议 live 补一句确认。
2)正常 bot-to-bot interactive @ 是否被误吞 — 不会 ✔
- 路由决策
decideRoutingWithSource只看root_id/thread_id/chat_type/chat_mode,不读 content;maybeApplyForceTopicOverride等对结构化卡取.text得 null(与旧简化 content 取 "请升级" 一样都不命中/t)→ 路由行为零变化。 - 无 sentinel 的普通卡:提前解析出的最终 content 与 handler 自身
resolveNonsupportMessage解析结果完全一致(幂等),随后正常进handleThreadReply。新增does not over-suppress测试已覆盖。 - 唯一理论误吞:某 bot 转发/引用了一张真实授权通知卡(正文里嵌了 sentinel),可忽略。
3)解析幂等副作用 — 干净 ✔
- guard 是纯 early-return(
JSON.parse(content)?.[RESOLVED_TEXT_KEY] === 'string' → return),不改任何状态、不改message_type。 - 成功路径下 handler 的第二次调用是 no-op;下游
extractCardContent(短路RESOLVED_TEXT_KEY)/extractResources(走 structured)行为与「未提前解析」等价。
非阻塞发现
① [建议合并前修,一行] swallow guard 只认 message_type === 'interactive',漏了 'nonsupport'。
resolveNonsupportMessage 自身同时处理 'nonsupport' | 'interactive'(卡片两种投递都可能)。合并前逻辑:旧修法(bot 从不 <at>)下投递类型无所谓;本 PR 改成真实 @ 后,正确性依赖 swallow 对每一种投递路径都命中。若某个 bot app 把授权卡投成 nonsupport(本仓库其它路径确实预期这种类型),guard 被跳过 → 走到路由 → vetted sender(sibling / known-peer / team-bot / 已 grant)重现空会话。
→ 修法:if (message.message_type === 'interactive' || message.message_type === 'nonsupport')(免费、与 resolveNonsupportMessage 覆盖对齐);或用 live 日志确认授权卡在所有 bot app 上只会是 interactive。作者实测观察到的是 interactive,所以可能永不触发——但这一 || 消除了这个假设。
② [live 确认] 零宽保真(见重点 1)。 另可考虑:用卡片结构里的稳定标记识别授权通知卡(而非 content 子串),对 Lark 渲染更鲁棒;不过 sentinel 作为 MVP 可接受。
③ [效率/放大,提示] 早解析发生在 peer-vetting gate 之前。 每个 foreign-bot 的 interactive @ 现在都会先打 2 次 im.message.get,即便随后被 allowlist gate 丢弃(旧路径为 0 次)。有界(2 次、需 @ 本 bot),且早解析对正确性是必要的(刚 grant 的 sender 能过 gate,必须在 gate 前吞),故属固有成本,仅记录。
④ [nit] "对已合并卡幂等" 仅在解析成功路径成立。 若 resolveEventCard 回退到 unwrapUserDsl(REST 无返回),content 无 RESOLVED_TEXT_KEY,handler 的第二次 resolveNonsupportMessage 会再取一次。回退是 REST 失败的罕见分支,benign;只是 PR 描述的"幂等"精确来说限于成功路径。
⑤ [out-of-scope 跟进] buildQuotaExhaustedCard 同样 <at> 了(可能是 bot 的)grantee 但未带 sentinel。 与本 PR 修复的是同一类问题(通知卡 @ bot → 可能唤醒),本 PR 未触及,留作后续跟进项即可。
综上:核心正确、测试有意义且隔离绿、build/tsc 绿、路由无回归。发现①建议合并前顺手加固(一行 + 一条 nonsupport 回归测试),②建议 live 确认吞掉链路;两者都不构成阻塞。
背景:授权成功通知卡对 bot grantee 在名字缺失时展示裸 ou_ open_id,可读性差 (申晗最初吐槽点)。旧逻辑对 bot 一律纯文本名字,取不到名字就退回裸 open_id。 改动 buildGrantNotifyCard 为混合规则: - bot grantee 有名字 → 纯文本名字(不 <at>,不产生 mention、不唤醒对方 bot); - bot grantee 拿不到名字 → <at id> 兜底,飞书据 open_id 展示对方身份(远比裸 open_id 可读),代价是可能偶尔触发对方 bot 一次空会话(产品上可接受,名字缺失是少数边角情况); - 真人 grantee → 一律 <at>(真人被 @ 不会自动开会话)。 放弃先前「@ 全部 + 零宽 sentinel + dispatcher 路由层吞纯通知」的方案:其正确性依赖 sentinel 在每种投递路径(interactive 简化/REST 完整卡、interactive/nonsupport 两种 message_type)都可靠命中,较脆弱。本方案只在名字缺失的边角才 @ bot、直接接受偶发空 会话,无需 sentinel 机制,实现更简单、不新增路由层依赖。 影响面:仅 im/lark 授权通知卡渲染(card-builder.buildGrantNotifyCard);不动 event-dispatcher / message-parser / CLI / 后端;grant 主流程与真人授权行为不变。 验证:tsc --noEmit ✅;vitest grant-card + card-handler-grant + event-dispatcher + message-parser(286 tests)✅;新增/改动 grant 用例单跑(防 mock 泄漏)✅。
c49ab83 to
54ebea4
Compare
已按 @deepcoldy 的决策重写实现(HEAD →
|
|
Codex 二审:改动范围清晰,bot 有名字时避免唤醒、无名字时 @ 兜底、真人保持 @,测试覆盖充分,未发现阻塞问题。可以合并。 |
背景
授权成功通知卡对 bot 被授权人在名字缺失时会展示裸
ou_...open_id,可读性很差。改动
buildGrantNotifyCard改为混合规则(src/im/lark/card-builder.ts):<at>,不产生 mention、不唤醒对方 bot)。<at id>兜底:飞书据 open_id 展示对方身份(远比裸ou_可读),代价是可能偶尔触发对方 bot 一次空会话(产品上可接受,且名字缺失是少数边角情况)。<at>点名(真人被 @ 不会自动开会话)。为什么不用零宽 sentinel 方案
先前实现是「@ 全部被授权人 + 给卡片附零宽路由标记 + 接收 bot 在 dispatcher 路由层识别标记吞掉纯通知」。review 发现它的正确性依赖 sentinel 在每一种投递路径(interactive 简化占位 / REST 完整卡、且
message_type可能是interactive或nonsupport)都可靠命中,较脆弱。本方案只在名字缺失的边角才@bot、并直接接受偶发一次空会话,无需 sentinel 机制,实现更简单、不新增 dispatcher / message-parser 依赖。影响面
im/lark授权通知卡渲染;不涉及其它 IM。event-dispatcher/message-parser(已回退到 master 基线)。验证
pnpm build(tsc --noEmit)✅pnpm vitest run test/grant-card.test.ts test/card-handler-grant.test.ts test/event-dispatcher.test.ts test/message-parser.test.ts✅(286 tests)