主线是把答案里的引用从死字变成入口。v0.1.22 把 wiki 接进了聊天窗口,但 [[甲实体]] 还只是
四个字:想看那一页,得回头去 Web 或命令行。本版补这最后一公里——新增零 LLM 的 /page(所有平台
都能手打),飞书上再把这些引用做成按钮。
形态上的那一句是:点击 = 一条合成的入站消息。适配器只把核心生成的命令文本塞进按钮、回调时原样
取回,它不知道 /page 是什么;于是点击原路走回同一条入站流,五道闸全部复用、问答那条路零改、核心
零平台分支。不是新宿主、不开监听端口、不引入任何写路径——决策P4.21-21(无监听面)与决策P4.21-8
(KB 零字节写)都继续成立。无接口破坏、无新退出码、依赖零变更。
主链路已过真机(2026-08-17),但几条仍未核对,逐条见 docs/P4.22-IM可点引用.md §9——那一节记的是
"观测到了什么",不是"通过了"。
新增
- IM 可点引用:
/page+ 飞书卡片回传交互(P4.22,见
docs/P4.22-IM可点引用.md) —— 答案里的[[某页]]在飞书里可以点,
点完机器人把那一页发回来。不是新宿主、不开监听端口、不引入任何写路径:回调走 P4.21 已经建好的那条
WS 长连(决策P4.21-21 无监听面继续成立),/page只读盘(决策P4.21-8 KB 零字节写继续成立)。/page <页面名>(所有平台,零 LLM) —— 按名开页,复用 P3.8 的单一 owner 归口
(link_resolution_index+resolve_owner),故精确 stem / 别名(P3.1)/ 安全 fold variant(P3.8)
三种写法都认,与check/graph/heal/Web 是同一张解析表——IM 侧另写一套按名查找就是给这个系统开
第五个口径。未命中降级为一次 top-3 检索(手打错别字才是未命中的现实来源,而 BM25 + CJK 2-gram 正擅长救它)。
截断告示不附 Web 入口:内容在盘上,但 Web 没有按名开页的路由,给出去仍是死链(决策P4.22-12)。- 点击 = 一条合成的入站消息(枢纽,决策P4.22-2) ——
Action携带的是核心生成的命令文本
("/page 甲实体"),适配器只负责把这句话塞进按钮、回调时原样取回,它不知道/page是什么。
于是点击原路走回adapter.inbound(),五道闸全部复用、问答那条路零改、核心零平台分支;
新增动作类型时适配器同样零改。 - 红线的闸在接收端,不在构造端(决策P4.22-14) ——
InboundMessage新增事实字段origin,
核心据此把合成消息能跑的命令收窄到ACTION_COMMANDS(v1 只有page)。只校验"以/开头"守不住:
classify()把未知斜杠命令当普通提问送进 LLM,一个被改动过的回传串就是一次不该发生的 LLM 调用。
/help、/new都是零 LLM 却不在白名单里——前者是噪音、后者会清掉用户的上下文。 chat_type只认见过的会话(决策P4.22-5) —— 回调不带这个字段,而open_chat_id的前缀区分不了
单聊与群。适配器为每条真实入站消息记一条(tenant, chat_id) → chat_type的事实缓存,回调只查这张表、
未命中即整条丢弃。飞书卡片可被转发,"卡片出现在哪里"不构成授权依据;allow_chats一个字都不进适配器
(有一条静态断言守着)。键的两半都取map_event的同源字段(header.tenant_key,不是
event.operator.tenant_key——两者在跨租户/外部群下可能不同值,取错的后果不是报错、是间歇性"会话已过期")。/page的输出也出按钮(决策P4.22-25)—— 于是可以一跳接一跳地在库里走。引导语是「本页引用的
页面」;自引用不出按钮(判据是解析到的 owner 而非名字,否则[[别名]]绕回本页那一路会漏);
长页被截断时尾巴里的引用不上卡片(同下一条)。一次/page只建一次解析表——open_page
回的是PageView(text, links),出按钮时在已备好的「键 → 拥有页」表上零盘 IO 过滤,不再解析第二遍全库
frontmatter(有一条计数用例守着:双解析不会让任何功能用例变红)。- 卡片上只有页面名,且只来自已送达的正文 —— 动作从实际成功送达的片里抽(决策P4.22-13/20):
split_for超限时会丢后缀,从完整答案抽会把正文里根本没出现过的页面名印在群内公开可见的卡片上。
出按钮前还会先解析一次,断链的引用不出按钮(不承诺一个不存在的东西);超出max_actions=8时显式告示
"另有 N 个",正文里的[[原文]]始终保留,故手打/page永远是可用的退路。去重按拥有页、不按名字——
同一页有别名与 fold variant 两类合法异写,只按名字去重会给出两个点开同一页的按钮,还白占按钮位、
把"另有 N 个"也算歪。 - 回执帧头还原(决策P4.22-26) —— 上面那层兼容层有一处自伤:SDK 写回执时复用的就是入站那个帧对象,
于是被我们改写过的type=event会跟着应答回去;平台若按帧型路由应答,那颗 toast 就没了(页面照样会到,
症状恰好是"点了、页面来了、没有任何反馈")。故在写出那一刻还原帧头再重新序列化,传递用ContextVar
以免并发两帧串台;探不到写出接缝就整层不启用,不留收不了尾的改写。 Intake在adapter.start()之后重读一次能力位(决策P4.22-27) —— 适配器会在start()里就地降级
(探不到卡片注册面即如此),而Intake是装配期构造的。今天恰好无害,但下一个"在start()里降级且被
Intake读到"的能力位会静默用陈旧值且无用例会红。- CARD 帧兼容层(决策P4.22-21) ——
lark-oapi的 WS 客户端对MessageType.CARD帧直接 return,
而只有 EVENT 分支才会走到卡片回调的注册表;"卡片回调到底以哪种帧下发"这件事的证据是矛盾的
(Go SDK 的 EVENT 分支注释 vs 上游 issue #126 的真机 200340 报告)。实现选择不选边:子类化 WS 客户端,
把 CARD 帧头就地改写成 EVENT 再委回父类——纯粹的超集,走 EVENT 时它一次都不触发。探不到私有接缝即
降级告警(不拒启),并配真 SDK 形状探针;上游修好即可整段删除。 - 个人微信一个字不变:
supports_actions=False,send_actions永不被调(有反向用例守着)。 guanlan im-identify期间不注册卡片回调(决策P4.22-24)—— 那个模式的安全性整个建立在
「绝不回复任何消息」上(决策P4.21-28),而卡片回调必须同步回一个 toast:上一次guanlan im
发出的卡片在这 300 秒里被点一下,就等于告诉对方机器人此刻正活着。故新增一个与group_wanted同款的
平台无关部署意图actions_wanted,identify 传 False ⇒ 连注册都不做。- 飞书新增一步后台配置:「事件与回调 → 回调 → 订阅方式:长连接」+
card.action.trigger。
漏配是静默的(卡片发得出、点了没反应、无日志),故首次发卡片时记一条可执行的 INFO 提示,
中英双语指南也各加了一节。
真机验收(2026-08-17,首次)
- 主链路已跑通:飞书上点卡片按钮 → 弹「已收到」→ 那一页发回来。顺带确证三条,都是从行为反推、
不是看了一眼(推理链逐条写在 §9):toast 走普通dict返回值的赌注成立(决策P4.22-22);回调的
open_chat_id与消息事件的chat_id同值(缓存未命中会回「会话已过期」并整条丢弃,而实际弹的是
「已收到」⇒ 缓存命中);卡片 2.0 的组件键原样就对,build_actions_card()一个字不用改——但只覆盖到
回调链必需的那些键,tag: "markdown"的渲染档与config两项照不到,仍挂在 §9。 - 仍未确认:卡片回调走 EVENT 还是 CARD 帧(当次未看 DEBUG 日志)。兼容层照留——它是纯超集,
留着的代价是两个私有方法名,删错的代价是按钮整个失效。另:toast 弹出不能反推帧类型,两种假说
都与之一致(决策P4.22-26 把回执帧头修对之后,这个观测就失去了区分能力)。 - 仍未验:未授权者点击、卡片转发后点击、3 秒超时重推。逐条见
docs/P4.22-IM可点引用.md§9。
Full Changelog: v0.1.22...v0.1.23