Skip to content

fix(grant): 授权通知卡 bot 有名字用名字、无名字@兜底#422

Merged
deepcoldy merged 1 commit into
masterfrom
fix/grant-notify-mention
Jul 10, 2026
Merged

fix(grant): 授权通知卡 bot 有名字用名字、无名字@兜底#422
deepcoldy merged 1 commit into
masterfrom
fix/grant-notify-mention

Conversation

@deepcoldy

@deepcoldy deepcoldy commented Jul 10, 2026

Copy link
Copy Markdown
Owner

背景

授权成功通知卡对 bot 被授权人在名字缺失时会展示裸 ou_... open_id,可读性很差。

改动

buildGrantNotifyCard 改为混合规则src/im/lark/card-builder.ts):

  • bot 有名字 → 纯文本名字(不 <at>,不产生 mention、不唤醒对方 bot)。
  • bot 拿不到名字<at id> 兜底:飞书据 open_id 展示对方身份(远比裸 ou_ 可读),代价是可能偶尔触发对方 bot 一次空会话(产品上可接受,且名字缺失是少数边角情况)。
  • 真人 → 一律 <at> 点名(真人被 @ 不会自动开会话)。

为什么不用零宽 sentinel 方案

先前实现是「@ 全部被授权人 + 给卡片附零宽路由标记 + 接收 bot 在 dispatcher 路由层识别标记吞掉纯通知」。review 发现它的正确性依赖 sentinel 在每一种投递路径(interactive 简化占位 / REST 完整卡、且 message_type 可能是 interactivenonsupport)都可靠命中,较脆弱。本方案只在名字缺失的边角才 @ bot、并直接接受偶发一次空会话,无需 sentinel 机制,实现更简单、不新增 dispatcher / message-parser 依赖。

影响面

  • 平台:仅飞书 im/lark 授权通知卡渲染;不涉及其它 IM。
  • CLI / 后端:不改动,逻辑在 session spawn 前。
  • 会话类型:本群/全局授权、单/多目标通知一致;grant 主流程与真人授权行为不变。
  • 不再改动 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)
  • 新增/改动 grant 用例单跑(防 mock 泄漏)✅

@deepcoldy deepcoldy left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-parser287 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不读 contentmaybeApplyForceTopicOverride 等对结构化卡取 .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 泄漏)✅。
@deepcoldy deepcoldy force-pushed the fix/grant-notify-mention branch from c49ab83 to 54ebea4 Compare July 10, 2026 06:25
@deepcoldy deepcoldy changed the title fix(grant): 授权通知卡改为@被授权人 fix(grant): 授权通知卡 bot 有名字用名字、无名字@兜底 Jul 10, 2026
@deepcoldy

Copy link
Copy Markdown
Owner Author

已按 @deepcoldy 的决策重写实现(HEAD → 54ebea4f

从「@ 全部被授权人 + 零宽 sentinel + dispatcher 路由层吞纯通知」改为更简单的混合方案

  • bot 有名字 → 纯文本名字(不 <at>、不产生 mention、不唤醒对方)
  • bot 无名字<at> 兜底(飞书据 open_id 展示身份,比裸 ou_ 可读;接受偶发一次空会话,产品上可接受,边角情况)
  • 真人 → 照旧 <at>

event-dispatcher.ts / message-parser.ts 已回退到 master 基线,不再需要 sentinel 机制——我先前 review 提的「swallow guard 漏 nonsupport 投递」隐患随之消失。净改动仅 card-builder.ts(buildGrantNotifyCard)+ 2 个测试,并删除了不再匹配的旧截图。

验证:tsc --noEmit ✅;grant-card + card-handler-grant + event-dispatcher + message-parser 286 tests ✅;新增/改动 grant 用例单跑(防 mock 泄漏)✅。

@deepcoldy

Copy link
Copy Markdown
Owner Author

Codex 二审:改动范围清晰,bot 有名字时避免唤醒、无名字时 @ 兜底、真人保持 @,测试覆盖充分,未发现阻塞问题。可以合并。

@deepcoldy deepcoldy merged commit e3237b6 into master Jul 10, 2026
1 check passed
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.

1 participant