命令头边界修复 + 清理零引用依赖 + 命令解析层选型收口(结论:不迁移 Alconna) #88

Closed
opened 2026-07-28 03:06:45 +00:00 by KumaAgent · 1 comment
Member

本正文已按 2026-07-29 的评估与 grill 结论重写(评估过程、证据与被推翻的论断见本 issue 的评论)。原正文提的三个待 grill 决策已全部拍板,范围随之收窄:不迁移 Alconna,本 ticket 变成「关掉命令头边界缺陷 + 清两条依赖 + 把命令解析层选型收口进 ADR」。

Problem

一、pyproject 里两条零引用依赖

nonebot-plugin-alconnapyproject.toml:11,注释「跨平台命令解析拓展」)与 nonebot-plugin-uninfo(同处,注释「多平台用户信息获取」)都是已声明依赖,但全仓 0 个文件引用:

$ git grep -l "nonebot_plugin_alconna\|nonebot_plugin_uninfo" origin/main -- src tests | wc -l   # 0

uninfo 更进一步:它硬依赖 nonebot-adapter-qq(→ cryptography/cffi/pycparser),让每一个部署强装一个平台适配器——CONTEXT.md「core……平台无关」在打包层直接不成立。

两个 host 都已自给自足,删掉今天零影响:

自己声明 alconna 自己声明 uninfo 已集成 arise
dxkuma-bot-ob11 >=0.62.0 + [tool.nonebot.plugins] 加载 不用 尚未集成
dxkuma-bot-qq >=0.62.0 + 加载 >=0.11.0 + 加载

二、on_command 的命令头没有词边界要求(main 上活着的缺陷)

nonebot/rule.py:104cls.prefix.longest_prefix(segment_text),纯前缀树最长匹配,命令头后不要求任何词边界。实测(真 nonebot,复刻 main 的注册):

'/cost'          -> key='/cost'     value=('cost',)
'/costume 3'     -> key='/cost'     value=('cost',)     ← 命中
'/why'           -> key='/why'      value=('why',)
'/whyfoo'        -> key='/why'      value=('why',)      ← 命中
'/settoolXYZ'    -> key='/settool'  value=('settool',)  ← 命中

_handle_cost(event) / _handle_why(event)__init__.py:982/1491不收 CommandArg,那截尾巴被整个丢掉。于是 @bot /costume 3 会走完整的 _admin_gate:管理员拿到成本报表,非管理员拿到 ADMIN_ONLY_REFUSAL

/why_query/why 的碰撞不是 bug——longest_prefix 正确选中更长的 /why_query,已实测。)

两条用户级同意命令有同形的潜在风险:它们不带参数,于是尾随语气助词会成为参数——/允许了解我的动态吗 今天会命中并授予跨平台资料拉取同意。当前两个 host 都未声明 COMMAND_START(走默认 {"/"}),自然中文不带 / 前缀所以今天不可达;但这是一道零成本就能关掉的、后果涉及隐私授权的潜在闸。

更正原正文的三处过期事实

  1. 不是「四个管理命令」,是 7 条——/cost(#81)、/why//why_query(#83)已合并。原 Not in scope 那条「/cost//why//why_query 各在自己的 ticket」已过期。__init__.py 里那段注释自己也还写着「四个运行时管理命令」,其下挂着 7 个 matcher。
  2. parse_tool_toggle 不是「约 20 行」,是 11 个代码行;真正的大头是 #83 引入的 explain.parse_why_query(29 代码行)——手写解析器现在有两个
  3. _argument_of 不是「手写的命令解析」:它的函数体只有一行 return command_arg.extract_plain_text().strip(),切分早已委托给 NoneBot 官方 CommandArg() DI。原正文「issue #80 在 on_command 之上手写了一层命令解析」这个立论前提不成立——#80 那条 bug 在第二个 commit 就已经用 CommandArg() DI 修掉了,core 侧现在没有任何「命令名到哪儿结束」的判断。

三个决策的结论

决策 1:不迁移到 on_alconna

  • 迁移消灭不了什么:上面第 3 条已说明,那条失败路径已经死了。迁移只是把切分从 NoneBot TrieRule 换成 Alconna——从「已经交出去」换成「交给另一个人」。
  • 迁移会新开一条成因公式相同的路径alconna_use_command_start 默认 False(实测 DIAGNOSE.parse('/diagnose').matched = False),而本仓 .env.testCOMMAND_START='["", "/"]'(含空串),裸命令在测试环境能命中。这正是 #80 那条 bug 的公式:测试环境宽松 + 生产严格 + 差异只在前缀层
  • auto_send_output 默认 True,且发送发生在 Rule 里、任何 handler 之前nonebot_plugin_alconna/rule.py:352-360),_admin_gate 来不及跑。后果是非管理员能拿到一次未经 is_admin 的回复,内容还是命令的参数文法——撞 ADR-0010「拒绝语只有一份常量、不按原因分叉」,且完全绕开 arise 的 egress port。
  • Alconna 表达不了既有的拒绝断言(≥6 条):settool weather ONparse_tool_toggle docstring 明写大小写不敏感是刻意的,Alconna Literal 大小写敏感)、why_query 看海 valence=0.5(Alconna MultiVar 贪婪吞尾部坐标判 matched=True,而 parse_why_query 明确拒绝)、valence=99/nan/inf(Alconna float pattern 全收,_parse_axismath.isfinite + 域校验挡掉)。要保住就得在 Alconna 之后再跑一遍后置校验——parse_why_query 大半原样留下 + 多一层解析,净收益为负。
  • 可测性是净损失use_cmd_start=True就地改写模块级 Alconna 对象的 prefixesrule.py:117-129,实测 SETTOOL.prefixes AFTER = ['', '/'] <-- mutated in place),纯函数性从「结构保证」降级为「依赖导入顺序的全局可变状态」。

必须如实记录的反向证据:命令头边界这一维上 alconna 确有一项真实收益——同一批输入喂给 Alconna(["/"], "cost") 全部 head_matched=False。本 ticket 用 force_whitespace=True 以低得多的代价拿到了同一个收益。这条不能因为结论是「不迁移」就省略,否则下一个人重开这个问题时会先撞上 /costume

决策 2:nonebot-plugin-uninfo 从 core 的 dependencies 移出(原 (b) 路线)

  • 见 Problem 一:它硬拖平台适配器,(a) 的全部收益是「host 不用自己写一行 dependencies」,而这份便利没有接收方。
  • 本项目已拍过三次同形的板:CONTEXT.md「MCP 的归一化是 host 的责任,arise core 不直接依赖任何 MCP 客户端库」;ADR-0011 拒绝「打包默认 TTS 引擎」(「硬塞引擎依赖给每个安装者」)与「本地 bge embedding」(「拖 torch 重依赖……违背可发布最小化」)。
  • nonebot-plugin-alconna 一并移出:不迁移即它 0 引用。加回来的成本是一行 pyproject + uv lock(且两个 host 进程里它本来就已作为 NoneBot 插件加载),而留着则 AC「不再存在声明了但 core 侧确实不需要的依赖」永远满足不了;「保留待用」这类注释历史上已经烂过一次——# 跨平台命令解析拓展 描述的正是当初的意图而非现实,然后它潜伏了整个 Phase 1-6。

已知耦合nonebot_plugin_uninfo/target.py:27-32to_target() 运行时 from nonebot_plugin_alconna import Target,失败则 RuntimeError。本 ticket 两条一起移出,不产生这个降级;但若将来只加回其一,需记得这条。(反方向无耦合:alconna 的 Requires-Dist 里 uninfo 零命中。)

决策 3:不接入 UniMessage,原正文把它排除在范围外是对的

  • ADR-0004 背景节那句是划作用域(「UniMessage 已抽象跨平台消息渲染与发送;缺口在非消息能力」),不是「拒绝了 UniMessage」——但结论不变:接入会与 ADR-0004 正面相撞(Receipt.recallable运行时探测,而 CapabilitySet docstring 逐字写着「不做运行时探测」;Receipt.recallexcept NotImplementedError: pass静默 no-op,撞「core 拥降级策略」+ ADR-0012 的 edit→recall+resend 链),且是 EgressPort 契约的破坏性变更。
  • 决策 1 选「不迁移」后,UniMessage 层根本不会进依赖树,本条自动落定,不产生任何独立工作项
  • 原正文引用的 ADR-0010 那句「动那一层会与 ADR-0004 的既有取舍正面相撞」措辞准确,不需要订正(评估初稿曾要求订正它,经复核确认是误读,已撤回)。

Acceptance criteria

命令头边界

  • 7 条管理命令(setmodel/settool/reloadprompt/diagnose/cost/why/why_query)+ 2 条用户级同意命令(允许了解我的动态/不再了解我的动态)全部加 force_whitespace=True
  • tests/test_admin_commands_dispatch.py边界回归断言:/costume 3 不触发 /cost/whyfoo 不触发 /why/settoolXYZ 不触发 /settool;同意命令补 /允许了解我的动态吗 不授予同意。
    • 不要再加一条「取参数」断言:_argument_of 是 4 条带参命令共用的一行,现有 settool weather off/setmodel gpt-4o/diagnose model 三条已经把它罩住,第 4 条是冗余。
  • 已知且接受的行为变化:中文紧贴命令名的写法(/why_query看海 valence=0.5/settool天气 off)会被静默拒绝。7 条里只有 /why_query 真吃自由中文(其余参数是模型名/工具名,走 ASCII)。在 /why_query 的用法说明里点明「查询词前要有空格」,并在测试里为这个行为留一条断言(是刻意取舍,不是回归)。

覆盖网升级为机械守卫

  • tests/test_admin_commands_dispatch.py 现在只 import 了 4 个 matcher,而 main 上有 7 条管理命令——#83 加 /why_query 时漏了这一步。不要用 docstring 提醒来补(用注释去补一个注释没拦住的坑):改为穷举守卫——枚举模块级管理命令 matcher(或显式集合常量)并断言「已覆盖集合 == 管理命令集合」,新增命令不加测试就红。
    • 本仓已有四处同形先例:tests/test_cost.py:61-65(TriggerPath)、tests/test_breaker.py:125-129(BreakerState)、tests/test_explain.py:231-232(人话表两张)、src/nonebot_plugin_arise/__init__.py:926(RuntimeContext);ADR-0010 issue #83 节明文背书过这个手法。

依赖

  • pyproject.tomlnonebot-plugin-alconnanonebot-plugin-uninfo 两行,uv lock 同步(预期一并掉 nonebot-adapter-qq / cryptography / cffi / pycparser)。
  • 补齐原 AC 第 4 条欠的后半句:每个保留项的注释说清它是被谁用的(core 直接用 / 传递依赖 / 留给 host)。当前 any-llm-sdk/httpx/nonebot-plugin-orm/nonebot2/qdrant-client 五条一条注释都没有localstore 的「# 存储文件」没说它是 orm 的传递依赖(而那正是 ADR-0010 给它豁免的全部理由)。写注释前逐条 git grep 核实真实引用点,不要照抄旧意图。
    • 不新建 CI 依赖审计脚本:按今天 9 条依赖实算,只管 nonebot-plugin-* 的话 4 条里触发 2 条,其中 localstore 是 ADR-0010 已豁免的必须进白名单——一上线就需要长期维护的例外表才能产出零信号。这条注释约定就是零维护的答案。
  • admin.pyIsAdmin docstring 补一段给 host 的指路,只写不会过期的边界陈述:uninfo 的 ADMIN() 答的是「这人在这个群是不是管理员」,而 IsAdmin 问的是「这人能不能管这个机器人」——把前者当后者,就是替所有部署做了该 docstring 明确拒绝做的那个选择。
    • 不要抄「19 个适配器里 7 个不产出 Role」这类版本绑定清单:移除依赖后 uninfo 不在 lock、不在 .venv、不被任何测试触及,那份清单在本仓永远无人可验证。需要提覆盖率时用不点名的形状(「并非所有适配器都产出 Role,host 需自行确认目标平台」)。

文档

  • ADR 一律只追加 ## 更新(2026-07-xx,issue #88) 节,不就地改写既有「决策」/「拒绝了」——本仓惯例,三条实证:ADR-0004 的「决策」节至今保留已被 ADR-0030 宣布移除的 poke 位;ADR-0010 用「上一条更新(issue #41)记的『尚未实现』已过时」推翻前文而不删前文;ADR-0001 的 #80 更新同形。
  • ADR-0011 新增「命令解析层」选型条目(该 ADR 现覆盖 substrate/存储/向量/embedding/TTS/阈值六项,缺这一项正是 alconna 反复冒头的根因):
    • 决策 = NoneBot 内建 on_command + CommandArg() DI + force_whitespace=True
    • 「拒绝了」写入 alconna,三条理由见决策 1,并如实写上「alconna 在命令头边界上确有一项收益、本 ticket 用 force_whitespace 以低得多的代价拿到了它」
    • 必答一条:为什么这次手写校验不算「重造」。ADR-0011 的「拒绝了」节用「代码面积大、违『别重造』」否掉过「裸 asyncpg + 手写 SQL/迁移」,本条不回应这个取向会读起来像无理由的例外。区分点:手写面只有 parse_tool_toggle 11 行 + parse_why_query 29 行(对比 8 张表全套 SQL 与迁移),且库表达不了既有 6+ 条拒绝断言、用了也得原样留后置校验。
    • 重评触发条件(两条触发 + 一条明确非触发):
      • 触发①:出现需要子命令或多选项文法的新管理命令(文法层级变了,如 /settool list|add|rm <name>;「参数变多」不算,两个位置参数现有手写解析吃得下)。
      • 触发②:第三个手写解析器出现(现有两个)。
      • 非触发:「又出了一条命令解析 bug」不构成触发——#80(整串当参数)与本次(/costume)的根因都是测试网没覆盖,不是框架不够强。把出 bug 写成触发条件会诱导下一个人错误归因到框架,而真正该补的是测试网。
    • 同时写明新依赖口径:「不为『host 可能用得上』声明依赖,尤其不声明会拖平台适配器的依赖」
      • ⚠️ 这是对本 issue 原 AC 第 1 条点名落点的一次有意偏离,已单独拍板:原 AC 写「命令层的形状属 ADR-0010 运营面 / ADR-0001 port 边界」,但 ADR-0001 通篇讲接口/回调边界、一字不涉打包(其中唯一的「打包」是「不打包任何 host preset」,讲的是内容不是依赖声明),而 ADR-0011 已有三条同形先例(bge / TTS / litellm 的「不该让全体安装者背」)。故口径落 ADR-0011,ADR-0001 不动。
      • 措辞注意:不要写成「只放 core 自己 import 或 require() 的东西」——httpxlocalstore 同样零引用却必须保留(ADR-0010 已明文给 localstore 开过口子:「它是 orm 的传递依赖……显式声明传递依赖是合理做法」),那样写会让 ADR-0011 与 ADR-0010 当场矛盾,并制造一条「规则本身被它治理的仓库违反」的落差。
  • ADR-0010 追加更新节收口 #80 留下的悬挂项:「用哪个框架解析命令是未定项」与「uninfo 该不该继续留在 core 的 dependencies 里」两条至此已决,指向 ADR-0011 新条目。注意那节挂着的是两个悬挂项外加一条对决策 3 的圈定,回填时别把整节标成已收口。
  • CONTEXT.md 可测性条(539-541 行)加一句,升格的是一对而非半句:凡经平台事件派发触达的入口(如管理命令),除核心逻辑纯函数直测外还须一层真实派发回归网——只有前者会漏掉前缀/边界层的 bug(#80 与本 ticket 各栽过一次)。
    • 背景:「处理逻辑可脱离真实事件派发直接测试」这句话此前只存在于 issue #80 的 AC,三份权威产物(CONTEXT.md/design.md/ADR-0010)里一个字都没有,且它们枚举的四个对象(门控级一打分/情感态方程/outbox 节奏/三因子召回)全是纯函数、没有一个带派发层。只升前半句是危险的:#80 那个 bug 恰恰是 29 条直调 _run_* 的单测在完全不碰前缀层的情况下全绿放过去的。
  • 顺手的诚实性修补(同源、低成本):__init__.py 那段「四个运行时管理命令」注释与其下 7 个 matcher 不符(末句「下面这四条一律经 ports.is_admin 校验」同理);admin.py:4-5 把「核心逻辑与调度驱动分离」当 ADR-0010 原话引用,而 ADR-0010 全文无此短语(grep「调度」只命中 #82 节的「各调度入口」)——要么改成转述措辞,要么在 ADR-0010 里补上这个说法使引用成真。

Not in scope

  • 不迁移到 on_alconna(决策 1);因此也不碰 parse_tool_toggle / parse_why_query / _argument_of / 四个薄 DI 壳 / 全部 _run_* —— 这些明确零改动
  • 不接入 UniMessage / 不碰 egress 消息层(决策 3),EgressPort 契约零改动。
  • 不碰 nonebot-plugin-localstorehttpx:两者虽同样零引用,但都是合理的显式传递依赖声明(localstore ← orm,见 ADR-0010 已有裁定;httpx ← any-llm-sdk / qdrant-client)。只补注释说清,不删。
  • 不新建 CI 依赖审计脚本(理由见依赖节)。
  • 不加「grep src/ 零处 UniMessage」这类扫源码文本的守卫:本仓无此测试体裁先例,且今天恒真、易被无关注释打红;pyproject.toml 的 ruff select 已经选上了 TIDflake8-tidy-importsbanned-api 是一行声明式禁令且严格更强(grep 拦不住 import UniMessage as U)。若真要守,用它。
  • 不订正 ADR-0010 关于 ADR-0004/UniMessage 的那句(经复核确认原措辞准确,见决策 3)。
  • 不改 ADR-0001(口径落 ADR-0011,见上)。

Blocked by

无。PR #87(issue #80)已合并。

> **本正文已按 2026-07-29 的评估与 grill 结论重写**(评估过程、证据与被推翻的论断见本 issue 的评论)。原正文提的三个待 grill 决策已全部拍板,范围随之收窄:**不迁移 Alconna**,本 ticket 变成「关掉命令头边界缺陷 + 清两条依赖 + 把命令解析层选型收口进 ADR」。 ## Problem ### 一、pyproject 里两条零引用依赖 `nonebot-plugin-alconna`([pyproject.toml:11](pyproject.toml#L11),注释「跨平台命令解析拓展」)与 `nonebot-plugin-uninfo`(同处,注释「多平台用户信息获取」)都是已声明依赖,但全仓 **0 个文件**引用: ``` $ git grep -l "nonebot_plugin_alconna\|nonebot_plugin_uninfo" origin/main -- src tests | wc -l # 0 ``` `uninfo` 更进一步:它硬依赖 `nonebot-adapter-qq`(→ cryptography/cffi/pycparser),**让每一个部署强装一个平台适配器**——CONTEXT.md「core……平台无关」在打包层直接不成立。 两个 host 都已自给自足,删掉今天零影响: | | 自己声明 alconna | 自己声明 uninfo | 已集成 arise | |---|---|---|---| | `dxkuma-bot-ob11` | ✅ `>=0.62.0` + `[tool.nonebot.plugins]` 加载 | ❌ 不用 | ❌ 尚未集成 | | `dxkuma-bot-qq` | ✅ `>=0.62.0` + 加载 | ✅ `>=0.11.0` + 加载 | ❌ | ### 二、`on_command` 的命令头没有词边界要求(main 上活着的缺陷) `nonebot/rule.py:104` 是 `cls.prefix.longest_prefix(segment_text)`,纯前缀树最长匹配,**命令头后不要求任何词边界**。实测(真 nonebot,复刻 main 的注册): ``` '/cost' -> key='/cost' value=('cost',) '/costume 3' -> key='/cost' value=('cost',) ← 命中 '/why' -> key='/why' value=('why',) '/whyfoo' -> key='/why' value=('why',) ← 命中 '/settoolXYZ' -> key='/settool' value=('settool',) ← 命中 ``` 而 `_handle_cost(event)` / `_handle_why(event)`(`__init__.py:982`/`1491`)**不收 `CommandArg`**,那截尾巴被整个丢掉。于是 `@bot /costume 3` 会走完整的 `_admin_gate`:管理员拿到成本报表,非管理员拿到 `ADMIN_ONLY_REFUSAL`。 (`/why_query` 与 `/why` 的碰撞**不是** bug——`longest_prefix` 正确选中更长的 `/why_query`,已实测。) **两条用户级同意命令有同形的潜在风险**:它们不带参数,于是尾随语气助词会成为参数——`/允许了解我的动态吗` 今天会命中并**授予跨平台资料拉取同意**。当前两个 host 都未声明 `COMMAND_START`(走默认 `{"/"}`),自然中文不带 `/` 前缀所以今天不可达;但这是一道零成本就能关掉的、后果涉及隐私授权的潜在闸。 ## 更正原正文的三处过期事实 1. **不是「四个管理命令」,是 7 条**——`/cost`(#81)、`/why`/`/why_query`(#83)已合并。原 Not in scope 那条「`/cost`/`/why`/`/why_query` 各在自己的 ticket」已过期。`__init__.py` 里那段注释自己也还写着「四个运行时管理命令」,其下挂着 7 个 matcher。 2. **`parse_tool_toggle` 不是「约 20 行」,是 11 个代码行**;真正的大头是 #83 引入的 `explain.parse_why_query`(29 代码行)——手写解析器现在有**两个**。 3. **`_argument_of` 不是「手写的命令解析」**:它的函数体只有一行 `return command_arg.extract_plain_text().strip()`,切分早已委托给 NoneBot 官方 `CommandArg()` DI。**原正文「issue #80 在 on_command 之上手写了一层命令解析」这个立论前提不成立**——#80 那条 bug 在第二个 commit 就已经用 `CommandArg()` DI 修掉了,core 侧现在没有任何「命令名到哪儿结束」的判断。 ## 三个决策的结论 ### 决策 1:**不迁移到 `on_alconna`** - 迁移消灭不了什么:上面第 3 条已说明,那条失败路径已经死了。迁移只是把切分从 NoneBot TrieRule 换成 Alconna——从「已经交出去」换成「交给另一个人」。 - 迁移会**新开一条成因公式相同的路径**:`alconna_use_command_start` 默认 `False`(实测 `DIAGNOSE.parse('/diagnose').matched = False`),而本仓 `.env.test` 是 `COMMAND_START='["", "/"]'`(含空串),裸命令在测试环境能命中。这正是 #80 那条 bug 的公式:**测试环境宽松 + 生产严格 + 差异只在前缀层**。 - **`auto_send_output` 默认 True,且发送发生在 Rule 里、任何 handler 之前**(`nonebot_plugin_alconna/rule.py:352-360`),`_admin_gate` 来不及跑。后果是非管理员能拿到一次未经 `is_admin` 的回复,内容还是命令的参数文法——撞 ADR-0010「拒绝语只有一份常量、不按原因分叉」,且完全绕开 arise 的 egress port。 - **Alconna 表达不了既有的拒绝断言**(≥6 条):`settool weather ON`(`parse_tool_toggle` docstring 明写大小写不敏感是刻意的,Alconna `Literal` 大小写敏感)、`why_query 看海 valence=0.5`(Alconna MultiVar 贪婪吞尾部坐标判 `matched=True`,而 `parse_why_query` 明确拒绝)、`valence=99/nan/inf`(Alconna float pattern 全收,`_parse_axis` 用 `math.isfinite` + 域校验挡掉)。要保住就得在 Alconna 之后再跑一遍后置校验——`parse_why_query` 大半原样留下 **+ 多一层解析**,净收益为负。 - **可测性是净损失**:`use_cmd_start=True` 会**就地改写模块级 Alconna 对象的 `prefixes`**(`rule.py:117-129`,实测 `SETTOOL.prefixes AFTER = ['', '/'] <-- mutated in place`),纯函数性从「结构保证」降级为「依赖导入顺序的全局可变状态」。 **必须如实记录的反向证据**:命令头边界这一维上 alconna **确有一项真实收益**——同一批输入喂给 `Alconna(["/"], "cost")` 全部 `head_matched=False`。本 ticket 用 `force_whitespace=True` 以低得多的代价拿到了同一个收益。这条不能因为结论是「不迁移」就省略,否则下一个人重开这个问题时会先撞上 `/costume`。 ### 决策 2:**`nonebot-plugin-uninfo` 从 core 的 dependencies 移出**(原 (b) 路线) - 见 Problem 一:它硬拖平台适配器,(a) 的全部收益是「host 不用自己写一行 dependencies」,而这份便利没有接收方。 - 本项目已拍过三次同形的板:CONTEXT.md「MCP 的归一化是 host 的责任,arise core 不直接依赖任何 MCP 客户端库」;ADR-0011 拒绝「打包默认 TTS 引擎」(「硬塞引擎依赖给每个安装者」)与「本地 bge embedding」(「拖 torch 重依赖……违背可发布最小化」)。 - **`nonebot-plugin-alconna` 一并移出**:不迁移即它 0 引用。加回来的成本是一行 pyproject + `uv lock`(且两个 host 进程里它本来就已作为 NoneBot 插件加载),而留着则 AC「不再存在声明了但 core 侧确实不需要的依赖」永远满足不了;「保留待用」这类注释历史上已经烂过一次——`# 跨平台命令解析拓展` 描述的正是当初的意图而非现实,然后它潜伏了整个 Phase 1-6。 **已知耦合**:`nonebot_plugin_uninfo/target.py:27-32` 的 `to_target()` 运行时 `from nonebot_plugin_alconna import Target`,失败则 `RuntimeError`。本 ticket 两条一起移出,不产生这个降级;但若将来只加回其一,需记得这条。(反方向无耦合:alconna 的 `Requires-Dist` 里 uninfo 零命中。) ### 决策 3:**不接入 UniMessage**,原正文把它排除在范围外是对的 - ADR-0004 背景节那句是**划作用域**(「UniMessage 已抽象跨平台消息渲染与发送;缺口在非消息能力」),不是「拒绝了 UniMessage」——但结论不变:接入会与 ADR-0004 正面相撞(`Receipt.recallable` 是**运行时探测**,而 `CapabilitySet` docstring 逐字写着「不做运行时探测」;`Receipt.recall` 的 `except NotImplementedError: pass` 是**静默 no-op**,撞「core 拥降级策略」+ ADR-0012 的 edit→recall+resend 链),且是 `EgressPort` 契约的破坏性变更。 - 决策 1 选「不迁移」后,UniMessage 层根本不会进依赖树,本条自动落定,**不产生任何独立工作项**。 - 原正文引用的 ADR-0010 那句「动那一层会与 ADR-0004 的既有取舍正面相撞」**措辞准确,不需要订正**(评估初稿曾要求订正它,经复核确认是误读,已撤回)。 ## Acceptance criteria ### 命令头边界 - [ ] 7 条管理命令(`setmodel`/`settool`/`reloadprompt`/`diagnose`/`cost`/`why`/`why_query`)+ 2 条用户级同意命令(`允许了解我的动态`/`不再了解我的动态`)全部加 `force_whitespace=True`。 - [ ] `tests/test_admin_commands_dispatch.py` 补**边界**回归断言:`/costume 3` 不触发 `/cost`、`/whyfoo` 不触发 `/why`、`/settoolXYZ` 不触发 `/settool`;同意命令补 `/允许了解我的动态吗` 不授予同意。 - **不要**再加一条「取参数」断言:`_argument_of` 是 4 条带参命令共用的一行,现有 `settool weather off`/`setmodel gpt-4o`/`diagnose model` 三条已经把它罩住,第 4 条是冗余。 - [ ] **已知且接受的行为变化**:中文紧贴命令名的写法(`/why_query看海 valence=0.5`、`/settool天气 off`)会被静默拒绝。7 条里只有 `/why_query` 真吃自由中文(其余参数是模型名/工具名,走 ASCII)。在 `/why_query` 的用法说明里点明「查询词前要有空格」,并在测试里为这个行为留一条断言(是刻意取舍,不是回归)。 ### 覆盖网升级为机械守卫 - [ ] `tests/test_admin_commands_dispatch.py` 现在只 import 了 4 个 matcher,而 main 上有 7 条管理命令——#83 加 `/why_query` 时漏了这一步。**不要用 docstring 提醒来补**(用注释去补一个注释没拦住的坑):改为**穷举守卫**——枚举模块级管理命令 matcher(或显式集合常量)并断言「已覆盖集合 == 管理命令集合」,新增命令不加测试就红。 - 本仓已有四处同形先例:`tests/test_cost.py:61-65`(TriggerPath)、`tests/test_breaker.py:125-129`(BreakerState)、`tests/test_explain.py:231-232`(人话表两张)、`src/nonebot_plugin_arise/__init__.py:926`(RuntimeContext);ADR-0010 issue #83 节明文背书过这个手法。 ### 依赖 - [ ] `pyproject.toml` 删 `nonebot-plugin-alconna` 与 `nonebot-plugin-uninfo` 两行,`uv lock` 同步(预期一并掉 nonebot-adapter-qq / cryptography / cffi / pycparser)。 - [ ] **补齐原 AC 第 4 条欠的后半句**:每个保留项的注释说清它是被谁用的(core 直接用 / 传递依赖 / 留给 host)。当前 `any-llm-sdk`/`httpx`/`nonebot-plugin-orm`/`nonebot2`/`qdrant-client` **五条一条注释都没有**;`localstore` 的「# 存储文件」没说它是 orm 的传递依赖(而那正是 ADR-0010 给它豁免的全部理由)。写注释前逐条 `git grep` 核实真实引用点,不要照抄旧意图。 - **不新建 CI 依赖审计脚本**:按今天 9 条依赖实算,只管 `nonebot-plugin-*` 的话 4 条里触发 2 条,其中 localstore 是 ADR-0010 已豁免的必须进白名单——一上线就需要长期维护的例外表才能产出零信号。这条注释约定就是零维护的答案。 - [ ] `admin.py` 的 `IsAdmin` docstring 补一段给 host 的指路,**只写不会过期的边界陈述**:uninfo 的 `ADMIN()` 答的是「这人在这个群是不是管理员」,而 `IsAdmin` 问的是「这人能不能管这个机器人」——把前者当后者,就是替所有部署做了该 docstring 明确拒绝做的那个选择。 - **不要**抄「19 个适配器里 7 个不产出 Role」这类版本绑定清单:移除依赖后 uninfo 不在 lock、不在 `.venv`、不被任何测试触及,那份清单在本仓永远无人可验证。需要提覆盖率时用不点名的形状(「并非所有适配器都产出 `Role`,host 需自行确认目标平台」)。 ### 文档 - [ ] **ADR 一律只追加 `## 更新(2026-07-xx,issue #88)` 节,不就地改写既有「决策」/「拒绝了」**——本仓惯例,三条实证:ADR-0004 的「决策」节至今保留已被 ADR-0030 宣布移除的 `poke` 位;ADR-0010 用「上一条更新(issue #41)记的『尚未实现』已过时」推翻前文而不删前文;ADR-0001 的 #80 更新同形。 - [ ] **ADR-0011 新增「命令解析层」选型条目**(该 ADR 现覆盖 substrate/存储/向量/embedding/TTS/阈值六项,**缺这一项正是 alconna 反复冒头的根因**): - 决策 = NoneBot 内建 `on_command` + `CommandArg()` DI + `force_whitespace=True`。 - 「拒绝了」写入 alconna,三条理由见决策 1,**并如实写上「alconna 在命令头边界上确有一项收益、本 ticket 用 `force_whitespace` 以低得多的代价拿到了它」**。 - 必答一条:**为什么这次手写校验不算「重造」**。ADR-0011 的「拒绝了」节用「代码面积大、违『别重造』」否掉过「裸 asyncpg + 手写 SQL/迁移」,本条不回应这个取向会读起来像无理由的例外。区分点:手写面只有 `parse_tool_toggle` 11 行 + `parse_why_query` 29 行(对比 8 张表全套 SQL 与迁移),且库表达不了既有 6+ 条拒绝断言、用了也得原样留后置校验。 - **重评触发条件**(两条触发 + 一条明确非触发): - 触发①:出现需要**子命令或多选项文法**的新管理命令(文法层级变了,如 `/settool list|add|rm <name>`;「参数变多」不算,两个位置参数现有手写解析吃得下)。 - 触发②:**第三个手写解析器出现**(现有两个)。 - **非触发**:「又出了一条命令解析 bug」不构成触发——#80(整串当参数)与本次(`/costume`)的根因**都是测试网没覆盖**,不是框架不够强。把出 bug 写成触发条件会诱导下一个人错误归因到框架,而真正该补的是测试网。 - 同时写明新依赖口径:**「不为『host 可能用得上』声明依赖,尤其不声明会拖平台适配器的依赖」**。 - ⚠️ **这是对本 issue 原 AC 第 1 条点名落点的一次有意偏离,已单独拍板**:原 AC 写「命令层的形状属 ADR-0010 运营面 / **ADR-0001 port 边界**」,但 ADR-0001 通篇讲接口/回调边界、**一字不涉打包**(其中唯一的「打包」是「不打包任何 host **preset**」,讲的是内容不是依赖声明),而 ADR-0011 已有三条同形先例(bge / TTS / litellm 的「不该让全体安装者背」)。故口径落 ADR-0011,ADR-0001 不动。 - 措辞注意:**不要写成「只放 core 自己 import 或 `require()` 的东西」**——`httpx` 与 `localstore` 同样零引用却必须保留(ADR-0010 已明文给 localstore 开过口子:「它是 orm 的传递依赖……显式声明传递依赖是合理做法」),那样写会让 ADR-0011 与 ADR-0010 当场矛盾,并制造一条「规则本身被它治理的仓库违反」的落差。 - [ ] **ADR-0010 追加更新节收口 #80 留下的悬挂项**:「用哪个框架解析命令是未定项」与「uninfo 该不该继续留在 core 的 dependencies 里」两条至此已决,指向 ADR-0011 新条目。注意那节挂着的是**两个**悬挂项外加一条对决策 3 的圈定,回填时别把整节标成已收口。 - [ ] **CONTEXT.md 可测性条(539-541 行)加一句**,升格的是**一对**而非半句:凡经平台事件派发触达的入口(如管理命令),除核心逻辑纯函数直测外**还须一层真实派发回归网**——只有前者会漏掉前缀/边界层的 bug(#80 与本 ticket 各栽过一次)。 - 背景:「处理逻辑可脱离真实事件派发直接测试」这句话此前**只存在于 issue #80 的 AC**,三份权威产物(CONTEXT.md/design.md/ADR-0010)里一个字都没有,且它们枚举的四个对象(门控级一打分/情感态方程/outbox 节奏/三因子召回)全是纯函数、没有一个带派发层。**只升前半句是危险的**:#80 那个 bug 恰恰是 29 条直调 `_run_*` 的单测在完全不碰前缀层的情况下全绿放过去的。 - [ ] **顺手的诚实性修补**(同源、低成本):`__init__.py` 那段「四个运行时管理命令」注释与其下 7 个 matcher 不符(末句「下面这四条一律经 `ports.is_admin` 校验」同理);`admin.py:4-5` 把「核心逻辑与调度驱动分离」当 ADR-0010 原话引用,而 ADR-0010 全文无此短语(grep「调度」只命中 #82 节的「各调度入口」)——要么改成转述措辞,要么在 ADR-0010 里补上这个说法使引用成真。 ## Not in scope - **不迁移到 `on_alconna`**(决策 1);因此也不碰 `parse_tool_toggle` / `parse_why_query` / `_argument_of` / 四个薄 DI 壳 / 全部 `_run_*` —— 这些**明确零改动**。 - **不接入 UniMessage / 不碰 egress 消息层**(决策 3),`EgressPort` 契约零改动。 - **不碰 `nonebot-plugin-localstore` 与 `httpx`**:两者虽同样零引用,但都是合理的显式传递依赖声明(localstore ← orm,见 ADR-0010 已有裁定;httpx ← any-llm-sdk / qdrant-client)。只补注释说清,不删。 - **不新建 CI 依赖审计脚本**(理由见依赖节)。 - **不加「grep `src/` 零处 UniMessage」这类扫源码文本的守卫**:本仓无此测试体裁先例,且今天恒真、易被无关注释打红;`pyproject.toml` 的 ruff `select` **已经选上了 `TID`**,`flake8-tidy-imports` 的 `banned-api` 是一行声明式禁令且严格更强(grep 拦不住 `import UniMessage as U`)。若真要守,用它。 - **不订正 ADR-0010 关于 ADR-0004/UniMessage 的那句**(经复核确认原措辞准确,见决策 3)。 - **不改 ADR-0001**(口径落 ADR-0011,见上)。 ## Blocked by 无。PR #87(issue #80)已合并。
Author
Member

评估结论(2026-07-29)

三个决策全部有结论。评估方式:4 路并行侦察(introspect 实装 alconna 0.62.0 / uninfo 0.11.1、通读现有命令层与测试、逐字核对 ADR)→ 3 份带论据的建议 → 每份 2 个不同视角的对抗性复核(红队反驳 + ADR 协调性)。6 个复核全部判 SOUND_WITH_CAVEATS,无 FLAWED,但红队推翻了一条承重论断,见下。

先更正本 issue 正文自己的三处过期事实(先更正再讨论,否则 AC 会照着错的范围写):

  1. 不是「四个管理命令」,是 7 条——/cost(#81)、/why//why_query(#83)已合并。正文 Not in scope 那条「/cost//why//why_query 各在自己的 ticket」已过期。顺带:__init__.py 里那段注释自己也还写着「四个运行时管理命令」,其下挂着 7 个 matcher。
  2. parse_tool_toggle 不是「约 20 行」,是 11 个代码行;真正的大头是 #83 引入的 explain.parse_why_query(29 代码行)——手写解析器现在有两个,不是一个。
  3. AC 第 4 条的后半句未被任何人交付:「每个保留项的注释说清它是被谁用的」——any-llm-sdk/httpx/nonebot-plugin-orm/nonebot2/qdrant-client 五条一条注释都没有localstore 的「# 存储文件」没说它是 orm 的传递依赖(而那正是 ADR-0010 给它豁免的全部理由)。

🔴 评估过程中发现 main 上一个活着的 bug(这才是本 ticket 真正该修的东西)

on_command 的命令头匹配没有词边界要求nonebot/rule.py:104cls.prefix.longest_prefix(segment_text),纯前缀树最长匹配。实测(真 nonebot,复刻 main 的 7 条注册):

'/cost'          -> key='/cost'     value=('cost',)
'/costume 3'     -> key='/cost'     value=('cost',)     ← 命中
'/why'           -> key='/why'      value=('why',)
'/whyfoo'        -> key='/why'      value=('why',)      ← 命中
'/settoolXYZ'    -> key='/settool'  value=('settool',)  ← 命中

_handle_cost(event) / _handle_why(event)__init__.py:982/1491不收 CommandArg,那截尾巴被整个丢掉。于是 @bot /costume 3 会走完整的 _admin_gate:管理员拿到成本报表,非管理员拿到 ADMIN_ONLY_REFUSAL

/why_query/why 的碰撞不是 bug——longest_prefix 正确选中更长的 /why_query,已实测。)

修法是一个关键字参数,不是换框架:on_command(..., force_whitespace=True)nonebot/rule.py:376-389 的判定链是

if self.force_whitespace is None or not cmd_arg:
    return True          # ← 零参数命令(/cost /why /reloadprompt /diagnose)原样通过
return self.force_whitespace == (cmd_whitespace is not None)

TrieRule.get_value 只在命令头后确实有空白时才设 CMD_WHITESPACErule.py:113-125arg_str = segment_text[len(pf.key):]/costume 3arg_strume 3lstrip 后长度不变 → 不设)。所以 /costume 3/whyfoo/settoolXYZ 被拒,而 /cost/settool weather off/setmodel gpt-4o/why_query valence=0.5 x 全部照常。零行新代码、零测试改写。


决策 1:四个(实为 7 条)管理命令是否迁到 on_alconna不迁移

理由:

  • #88 立论的那条动机已经不成立。 _argument_of__init__.py:843-853)的函数体只有一行 return command_arg.extract_plain_text().strip(),切分早已委托给 NoneBot 官方 CommandArg() DI。core 侧已经没有任何「命令名到哪儿结束」的判断。迁移只是把这份委托从 NoneBot TrieRule 换成 Alconna——从「已经交出去」换成「交给另一个人」。
  • 迁移会新开一条成因公式完全相同的路径。 实测 alconna_use_command_start 默认 FalseDIAGNOSE.parse('/diagnose').matched = False;而本仓 .env.testCOMMAND_START='["", "/"]'(含空串),裸命令在测试环境能命中。这正是 #80 那条 bug 的公式:测试环境宽松 + 生产严格 + 差异只在前缀层。#80 的失败模式是「参数变成整串」,迁移后的是「命令彻底哑掉」。
  • auto_send_output 默认 True,且发送发生在 Rule 里、任何 handler 之前nonebot_plugin_alconna/rule.py:352-360),_admin_gate 根本来不及跑。后果是非管理员能拿到一次未经 is_admin 的回复,内容还是命令的参数文法——正撞 ADR-0010 那句「拒绝语只有一份常量、不按原因分叉。分叉……等于让一次误触就能问出这个部署的管理员规则是怎么配的」;而且这条消息完全绕开 arise 的 egress port。
  • Alconna 表达不了既有的拒绝断言(实测 ≥6 条):settool weather ONparse_tool_toggle docstring 明写大小写不敏感是刻意的,Alconna Literal 大小写敏感)、why_query 看海 valence=0.5(Alconna MultiVar 贪婪吞尾部坐标判 matched=True,而 parse_why_query 明确拒绝,理由逐字记录:「会让人以为自己模拟了某个情感态而实际没有,那段噪声还会一起被 embed」)、valence=99/nan/inf(Alconna float pattern 全收,_parse_axismath.isfinite + 域校验挡掉)。要保住就得在 Alconna 之后再跑一遍后置校验——parse_why_query 大半原样留下 + 多一层解析
  • 可测性是净损失。 Alconna 对象确实能脱离 NoneBot parse(),但 use_cmd_start=True 时会就地改写模块级 Alconna 对象的 prefixesrule.py:117-129,侦察实测输出 SETTOOL.prefixes AFTER = ['', '/'] <-- mutated in place)。纯函数性从「结构保证」降级为「依赖导入顺序的全局可变状态」。

红队对本决策的一条重要更正(已采纳):评估最初写「迁移收益为零」,这是错的——上面那个命令头边界缺陷是 alconna 有而 on_command 没有的一项真实收益(同一批输入喂给 Alconna(["/"], "cost") 全部 head_matched=False)。但 force_whitespace=True 以低得多的代价拿到了同一个收益。这条必须如实写进 ADR,不能因为结论是「不迁移」就省略反向证据——否则下一个人重开这个问题时会先撞上 /costume

alconna 依赖怎么收场:见下方待拍板第 3 条。

决策 2:nonebot-plugin-uninfo 的去留 → 走 (b),从 core 的 dependencies 移出

  • 事实核实:uninfo → nonebot-adapter-qq → cryptography/cffi/pycparser。一条 core 从不 import 的依赖,硬拖一个平台适配器进每一个部署——CONTEXT.md「core……平台无关」在打包层直接不成立。
  • (a) 的全部收益是「host 不用自己写一行 dependencies」,而这份便利没有接收方dxkuma-bot-qq 早已自己声明了 nonebot-plugin-uninfo>=0.11.0dxkuma-bot-ob11 的 uv.lock 里 arise 出现 0 次(尚未集成)。
  • 本项目已经拍过三次同形的板:CONTEXT.md「MCP 的归一化是 host 的责任,arise core 不直接依赖任何 MCP 客户端库」;ADR-0011 拒绝「打包默认 TTS 引擎」与「本地 bge embedding(拖 torch)」——「为让部分部署方便而让全体安装者背一个依赖」这个交换被明确拒绝过两次。

三条更正(已采纳):

  1. 提议的 ADR 口径「core 的 dependencies 只放 core 自己 import 或 require() 的东西」写下去当天就被自己治理的仓库违反——httpxlocalstore 同样零引用,而两者都必须留(ADR-0010 已明文给 localstore 开过口子:「它是 orm 的传递依赖……显式声明传递依赖是合理做法」)。改用能真正区分的措辞:「不为『host 可能用得上』声明依赖,尤其不声明会拖平台适配器的依赖」——这样 httpx/localstore 自然留下、uninfo 自然出局,不需要维护白名单。
  2. 不要把「19 个适配器里 7 个不产出 Role」写进 core 的 docstring。移除依赖后 uninfo 不在 arise 的 lock、不在 .venv、不被任何测试触及,这份版本绑定的清单在本仓永远无人可验证。保留那条不会过期的边界陈述即可:uninfo 的 ADMIN() 答的是「这人在这个群是不是管理员」,而 IsAdmin 问的是「这人能不能管这个机器人」——把前者当后者,就是替所有部署做了 admin.py docstring 明确拒绝做的那个选择。
  3. 不做 CI 依赖审计脚本。按今天 9 条依赖实算:只管 nonebot-plugin-* 的话,4 条里触发 2 条,其中 localstore 是 ADR-0010 已豁免的必须进白名单——一上线就需要长期维护的例外表才能产出零信号。AC 第 4 条那句「每个保留项的注释说清它是被谁用的」是同一问题零维护的答案,而且它本来就还欠着。

跨决策耦合(新发现)nonebot_plugin_uninfo/target.py:27-32to_target() 运行时 from nonebot_plugin_alconna import Target,失败则 raise RuntimeError。所以「两者完全独立」要降级为:移除 alconna 会让保留下来的 uninfo 少一个函数可用。(反方向无耦合:alconna 的 Requires-Dist 里 uninfo 零命中,已核实。)

决策 3:UniMessage 是否一并接入 → 不接入,且本 issue 把它排除在范围外是对的

  • ADR-0004 背景节那句是划作用域(「UniMessage 已抽象跨平台消息渲染与发送;缺口在非消息能力」),不是「拒绝了 UniMessage」。但这不改变结论:接入会与 ADR-0004 正面相撞——Receipt.recallable运行时探测,而 CapabilitySet docstring 逐字写着「不做运行时探测」;Receipt.recallexcept NotImplementedError: pass静默 no-op,撞 ADR-0004「core 拥降级策略」+ ADR-0012 的 edit→recall+resend 链。
  • 接入还是 EgressPort 契约的破坏性变更(九个方法载荷全是裸标量)。

三条更正(已采纳):

  1. 撤回「必须订正 ADR-0010 第 179 行」这个要求。 红队逐字核对后证明这是误读:原句没有说「ADR-0004 拒绝了 UniMessage」,它准确引用了背景节原文、然后说「动那一层会与 ADR-0004 的既有取舍正面相撞」——而上面那四条相撞恰恰证明这个措辞是准确的。按原提议改写会让一份已合并 ADR 更不准确。
  2. auto_send_output 不是决策 1 与 3 唯一的耦合点,更大的一块在 ingress 侧nonebot_plugin_alconna/extension.py:185-214message_provider每一条 message 事件、在任何 head 匹配和任何权限判定之前UniMessage.of(message, bot);OB11 的 MessageEventoriginal_message,于是每条消息还会再造第二个 UniMessage 并跑 attach_replyextension.py:177-178 是两个进程级 LRU(16),跨 chat 保留最近 16 条消息内容。即:若决策 1 迁移,UniMessage 进的是入站热路径,不只是「依赖树里多 48 个模块」。
  3. 不加「grep src/ 零处 UniMessage」的守卫。 本仓没有任何扫源码文本的测试先例,这条今天恒真、且任何在 ports.py docstring 里解释「本层刻意不用 UniMessage」的注释都会把它打红。而 pyproject.toml 的 ruff select 已经选上了 TID——flake8-tidy-importsbanned-api 是一行声明式禁令,且严格更强(grep 拦不住 import UniMessage as U,禁 import 路径拦得住)。

结论:本 ticket 应该做什么

范围从「迁移 + 清依赖」收缩为「关掉命令头边界缺陷 + 清一条依赖 + 把选型收口进 ADR」:

  1. 7 条 on_command 全部加 force_whitespace=True
  2. tests/test_admin_commands_dispatch.py边界回归断言(/costume 3 不触发 /cost/whyfoo 不触发 /why/settoolXYZ 不触发 /settool)。注意:不是再加一条取参数断言——_argument_of 是 4 条带参命令共用的一行,现有 settool weather off/setmodel gpt-4o/diagnose model 三条已经把它罩住了,再加第 4 条是冗余。
  3. 覆盖网升级为机械守卫而不是 docstring 提醒:枚举模块级管理命令 matcher 并断言「已覆盖集合 == 管理命令集合」,新增命令不加测试就红。本仓已有四处穷举守卫先例(test_cost.py TriggerPath、test_breaker.py BreakerState、test_explain.py 人话表、__init__.py:926 RuntimeContext),且 ADR-0010 #83 节明文背书过这个手法——#83 加 /why_query 时漏掉派发测试,正是「靠人记得」的失效实例。用注释去补一个注释没拦住的坑,是原方案内部最不一致的一处。
  4. pyproject.tomlnonebot-plugin-uninfo 那行 + uv lock;并补齐 AC 第 4 条欠的注释(5 条依赖当前零注释)。
  5. admin.pyIsAdmin docstring 补一段给 host 的指路(只写不会过期的边界陈述,见决策 2 更正 2)。
  6. ADR:按本仓惯例只追加 ## 更新(日期,issue #88) 节,不就地改写既有「决策」/「拒绝了」(ADR-0004 至今保留已被 ADR-0030 移除的 poke 位、ADR-0010 用「上一条更新已过时」推翻前文而不删前文——三条同形先例)。落点:ADR-0010 收口 #80 留下的两个悬挂项;ADR-0011 新增「命令解析层」选型条目(这一项的缺位正是 alconna 反复冒头的根因),必须写明重评触发条件(需要子命令/选项文法、或第三个手写解析器出现)+ 如实记录 alconna 在命令头边界上确有一项收益、我们用 force_whitespace 以低得多的代价拿到了它

顺手的诚实性修补(同源、低成本):__init__.py 那段「四个运行时管理命令」注释与其下 7 个 matcher 不符;admin.py:4-5 把「核心逻辑与调度驱动分离」当 ADR-0010 原话引用,而 ADR-0010 全文无此短语。

仍需人类拍板(评估刻意不替你决定)

  1. 路线图上是否已有需要子命令或多选项文法的管理命令规划? 这是唯一能推翻「不迁移」的信息。(附带:即便真出现,parse_why_query 那 6+ 条 Alconna 表达不了的拒绝规则依然要后置校验,所以翻盘门槛比看上去还高。)
  2. nonebot-plugin-alconna 是彻底移出,还是保留但把注释改成诚实的「保留待用,当前 0 引用」? 它不像 uninfo 那样构成边界违规(不拖任何平台适配器,uniseg 的 28 个 adapter 是惰性 Loader 桩),所以移除的理由只是未用依赖卫生,强度弱于移除 uninfo。注意两个 host 都已自行声明了 alconna 并在 [tool.nonebot.plugins] 里显式加载;另注意上面那条 to_target() 耦合。
  3. 「处理逻辑可脱离真实事件派发直接测试」要不要从 #80 的 AC 升格为 CONTEXT.md/ADR-0010 明文约束? 核实:三份权威产物里一个字都没有,只有「core 从一开始为可测而分层」这条通则做靠山,且明文枚举的四个对象不含管理命令。
  4. 新口径落 ADR-0001 还是 ADR-0011? 本 issue 的 AC 第 1 条点名的是 ADR-0001,但 ADR-0001 通篇讲接口/回调边界、一字不涉打包。评估倾向 ADR-0011(选型/依赖那份)——但这是对 AC 明文落点的一次偏离,按本仓「偏离 AC 明文需要单独拍板」的纪律,显式标出来请你定。
## 评估结论(2026-07-29) 三个决策全部有结论。评估方式:4 路并行侦察(introspect 实装 alconna 0.62.0 / uninfo 0.11.1、通读现有命令层与测试、逐字核对 ADR)→ 3 份带论据的建议 → 每份 2 个不同视角的对抗性复核(红队反驳 + ADR 协调性)。6 个复核全部判 `SOUND_WITH_CAVEATS`,无 `FLAWED`,但红队推翻了一条承重论断,见下。 **先更正本 issue 正文自己的三处过期事实**(先更正再讨论,否则 AC 会照着错的范围写): 1. **不是「四个管理命令」,是 7 条**——`/cost`(#81)、`/why`/`/why_query`(#83)已合并。正文 Not in scope 那条「`/cost`/`/why`/`/why_query` 各在自己的 ticket」已过期。顺带:`__init__.py` 里那段注释自己也还写着「四个运行时管理命令」,其下挂着 7 个 matcher。 2. **`parse_tool_toggle` 不是「约 20 行」,是 11 个代码行**;真正的大头是 #83 引入的 `explain.parse_why_query`(29 代码行)——手写解析器现在有**两个**,不是一个。 3. **AC 第 4 条的后半句未被任何人交付**:「每个保留项的注释说清它是被谁用的」——`any-llm-sdk`/`httpx`/`nonebot-plugin-orm`/`nonebot2`/`qdrant-client` 五条**一条注释都没有**;`localstore` 的「# 存储文件」没说它是 orm 的传递依赖(而那正是 ADR-0010 给它豁免的全部理由)。 --- ## 🔴 评估过程中发现 main 上一个活着的 bug(这才是本 ticket 真正该修的东西) `on_command` 的命令头匹配**没有词边界要求**。`nonebot/rule.py:104` 是 `cls.prefix.longest_prefix(segment_text)`,纯前缀树最长匹配。实测(真 nonebot,复刻 main 的 7 条注册): ``` '/cost' -> key='/cost' value=('cost',) '/costume 3' -> key='/cost' value=('cost',) ← 命中 '/why' -> key='/why' value=('why',) '/whyfoo' -> key='/why' value=('why',) ← 命中 '/settoolXYZ' -> key='/settool' value=('settool',) ← 命中 ``` 而 `_handle_cost(event)` / `_handle_why(event)`(`__init__.py:982`/`1491`)**不收 `CommandArg`**,那截尾巴被整个丢掉。于是 `@bot /costume 3` 会走完整的 `_admin_gate`:管理员拿到成本报表,非管理员拿到 `ADMIN_ONLY_REFUSAL`。 (`/why_query` 与 `/why` 的碰撞不是 bug——`longest_prefix` 正确选中更长的 `/why_query`,已实测。) **修法是一个关键字参数**,不是换框架:`on_command(..., force_whitespace=True)`。`nonebot/rule.py:376-389` 的判定链是 ```python if self.force_whitespace is None or not cmd_arg: return True # ← 零参数命令(/cost /why /reloadprompt /diagnose)原样通过 return self.force_whitespace == (cmd_whitespace is not None) ``` `TrieRule.get_value` 只在命令头后确实有空白时才设 `CMD_WHITESPACE`(`rule.py:113-125`:`arg_str = segment_text[len(pf.key):]`,`/costume 3` 的 `arg_str` 是 `ume 3`,`lstrip` 后长度不变 → 不设)。所以 `/costume 3`、`/whyfoo`、`/settoolXYZ` 被拒,而 `/cost`、`/settool weather off`、`/setmodel gpt-4o`、`/why_query valence=0.5 x` 全部照常。零行新代码、零测试改写。 --- ## 决策 1:四个(实为 7 条)管理命令是否迁到 `on_alconna` → **不迁移** **理由:** - **#88 立论的那条动机已经不成立。** `_argument_of`(`__init__.py:843-853`)的函数体只有一行 `return command_arg.extract_plain_text().strip()`,切分早已委托给 NoneBot 官方 `CommandArg()` DI。core 侧**已经没有任何**「命令名到哪儿结束」的判断。迁移只是把这份委托从 NoneBot TrieRule 换成 Alconna——从「已经交出去」换成「交给另一个人」。 - **迁移会新开一条成因公式完全相同的路径。** 实测 `alconna_use_command_start` 默认 `False`,`DIAGNOSE.parse('/diagnose').matched = False`;而本仓 `.env.test` 是 `COMMAND_START='["", "/"]'`(含空串),裸命令在测试环境能命中。这正是 #80 那条 bug 的公式:**测试环境宽松 + 生产严格 + 差异只在前缀层**。#80 的失败模式是「参数变成整串」,迁移后的是「命令彻底哑掉」。 - **`auto_send_output` 默认 True,且发送发生在 Rule 里、任何 handler 之前**(`nonebot_plugin_alconna/rule.py:352-360`),`_admin_gate` 根本来不及跑。后果是非管理员能拿到一次未经 `is_admin` 的回复,内容还是命令的参数文法——正撞 ADR-0010 那句「拒绝语只有一份常量、不按原因分叉。分叉……等于让一次误触就能问出这个部署的管理员规则是怎么配的」;而且这条消息完全绕开 arise 的 egress port。 - **Alconna 表达不了既有的拒绝断言**(实测 ≥6 条):`settool weather ON`(`parse_tool_toggle` docstring 明写大小写不敏感是刻意的,Alconna `Literal` 大小写敏感)、`why_query 看海 valence=0.5`(Alconna MultiVar 贪婪吞尾部坐标判 `matched=True`,而 `parse_why_query` 明确拒绝,理由逐字记录:「会让人以为自己模拟了某个情感态而实际没有,那段噪声还会一起被 embed」)、`valence=99/nan/inf`(Alconna float pattern 全收,`_parse_axis` 用 `math.isfinite` + 域校验挡掉)。要保住就得在 Alconna 之后再跑一遍后置校验——`parse_why_query` 大半原样留下 **+ 多一层解析**。 - **可测性是净损失。** Alconna 对象确实能脱离 NoneBot `parse()`,但 `use_cmd_start=True` 时会**就地改写模块级 Alconna 对象的 `prefixes`**(`rule.py:117-129`,侦察实测输出 `SETTOOL.prefixes AFTER = ['', '/'] <-- mutated in place`)。纯函数性从「结构保证」降级为「依赖导入顺序的全局可变状态」。 **红队对本决策的一条重要更正(已采纳)**:评估最初写「迁移收益为零」,这是**错的**——上面那个命令头边界缺陷是 alconna 有而 `on_command` 没有的一项真实收益(同一批输入喂给 `Alconna(["/"], "cost")` 全部 `head_matched=False`)。但 `force_whitespace=True` 以低得多的代价拿到了同一个收益。**这条必须如实写进 ADR,不能因为结论是「不迁移」就省略反向证据**——否则下一个人重开这个问题时会先撞上 `/costume`。 **alconna 依赖怎么收场**:见下方待拍板第 3 条。 ## 决策 2:`nonebot-plugin-uninfo` 的去留 → **走 (b),从 core 的 dependencies 移出** - 事实核实:uninfo → `nonebot-adapter-qq` → cryptography/cffi/pycparser。**一条 core 从不 import 的依赖,硬拖一个平台适配器进每一个部署**——CONTEXT.md「core……平台无关」在打包层直接不成立。 - (a) 的全部收益是「host 不用自己写一行 dependencies」,而这份便利**没有接收方**:`dxkuma-bot-qq` 早已自己声明了 `nonebot-plugin-uninfo>=0.11.0`,`dxkuma-bot-ob11` 的 uv.lock 里 arise 出现 0 次(尚未集成)。 - 本项目已经拍过三次同形的板:CONTEXT.md「MCP 的归一化是 host 的责任,arise core 不直接依赖任何 MCP 客户端库」;ADR-0011 拒绝「打包默认 TTS 引擎」与「本地 bge embedding(拖 torch)」——「为让部分部署方便而让全体安装者背一个依赖」这个交换被明确拒绝过两次。 **三条更正(已采纳):** 1. **提议的 ADR 口径「core 的 dependencies 只放 core 自己 import 或 `require()` 的东西」写下去当天就被自己治理的仓库违反**——`httpx` 与 `localstore` 同样零引用,而两者都必须留(ADR-0010 已明文给 localstore 开过口子:「它是 orm 的传递依赖……显式声明传递依赖是合理做法」)。改用能真正区分的措辞:**「不为『host 可能用得上』声明依赖,尤其不声明会拖平台适配器的依赖」**——这样 httpx/localstore 自然留下、uninfo 自然出局,不需要维护白名单。 2. **不要把「19 个适配器里 7 个不产出 `Role`」写进 core 的 docstring**。移除依赖后 uninfo 不在 arise 的 lock、不在 `.venv`、不被任何测试触及,这份版本绑定的清单在本仓**永远无人可验证**。保留那条不会过期的边界陈述即可:uninfo 的 `ADMIN()` 答的是「这人在这个群是不是管理员」,而 `IsAdmin` 问的是「这人能不能管这个机器人」——把前者当后者,就是替所有部署做了 `admin.py` docstring 明确拒绝做的那个选择。 3. **不做 CI 依赖审计脚本**。按今天 9 条依赖实算:只管 `nonebot-plugin-*` 的话,4 条里触发 2 条,其中 localstore 是 ADR-0010 已豁免的必须进白名单——一上线就需要长期维护的例外表才能产出零信号。AC 第 4 条那句「每个保留项的注释说清它是被谁用的」是同一问题零维护的答案,而且它本来就还欠着。 **跨决策耦合(新发现)**:`nonebot_plugin_uninfo/target.py:27-32` 的 `to_target()` 运行时 `from nonebot_plugin_alconna import Target`,失败则 `raise RuntimeError`。所以「两者完全独立」要降级为:**移除 alconna 会让保留下来的 uninfo 少一个函数可用**。(反方向无耦合:alconna 的 `Requires-Dist` 里 uninfo 零命中,已核实。) ## 决策 3:UniMessage 是否一并接入 → **不接入**,且本 issue 把它排除在范围外是对的 - ADR-0004 背景节那句是**划作用域**(「UniMessage 已抽象跨平台消息渲染与发送;缺口在非消息能力」),不是「拒绝了 UniMessage」。但这不改变结论:接入会与 ADR-0004 正面相撞——`Receipt.recallable` 是**运行时探测**,而 `CapabilitySet` docstring 逐字写着「不做运行时探测」;`Receipt.recall` 的 `except NotImplementedError: pass` 是**静默 no-op**,撞 ADR-0004「core 拥降级策略」+ ADR-0012 的 edit→recall+resend 链。 - 接入还是 `EgressPort` 契约的破坏性变更(九个方法载荷全是裸标量)。 **三条更正(已采纳):** 1. **撤回「必须订正 ADR-0010 第 179 行」这个要求。** 红队逐字核对后证明这是**误读**:原句没有说「ADR-0004 拒绝了 UniMessage」,它准确引用了背景节原文、然后说「动那一层会与 ADR-0004 的既有取舍正面相撞」——而上面那四条相撞恰恰证明这个措辞是准确的。按原提议改写会让一份已合并 ADR 更不准确。 2. **`auto_send_output` 不是决策 1 与 3 唯一的耦合点**,更大的一块在 **ingress 侧**:`nonebot_plugin_alconna/extension.py:185-214` 的 `message_provider` 对**每一条 message 事件**、在任何 head 匹配和任何权限判定**之前**做 `UniMessage.of(message, bot)`;OB11 的 `MessageEvent` 有 `original_message`,于是每条消息还会再造第二个 UniMessage 并跑 `attach_reply`;`extension.py:177-178` 是两个**进程级 LRU(16),跨 chat 保留最近 16 条消息内容**。即:若决策 1 迁移,UniMessage 进的是**入站热路径**,不只是「依赖树里多 48 个模块」。 3. **不加「grep `src/` 零处 UniMessage」的守卫。** 本仓没有任何扫源码文本的测试先例,这条今天恒真、且任何在 `ports.py` docstring 里解释「本层刻意不用 UniMessage」的注释都会把它打红。而 `pyproject.toml` 的 ruff `select` **已经选上了 `TID`**——`flake8-tidy-imports` 的 `banned-api` 是一行声明式禁令,且严格更强(grep 拦不住 `import UniMessage as U`,禁 import 路径拦得住)。 --- ## 结论:本 ticket 应该做什么 范围从「迁移 + 清依赖」收缩为「**关掉命令头边界缺陷 + 清一条依赖 + 把选型收口进 ADR**」: 1. 7 条 `on_command` 全部加 `force_whitespace=True`。 2. `tests/test_admin_commands_dispatch.py` 补**边界**回归断言(`/costume 3` 不触发 `/cost`、`/whyfoo` 不触发 `/why`、`/settoolXYZ` 不触发 `/settool`)。注意:**不是**再加一条取参数断言——`_argument_of` 是 4 条带参命令共用的一行,现有 `settool weather off`/`setmodel gpt-4o`/`diagnose model` 三条已经把它罩住了,再加第 4 条是冗余。 3. 覆盖网升级为**机械守卫**而不是 docstring 提醒:枚举模块级管理命令 matcher 并断言「已覆盖集合 == 管理命令集合」,新增命令不加测试就红。本仓已有四处穷举守卫先例(`test_cost.py` TriggerPath、`test_breaker.py` BreakerState、`test_explain.py` 人话表、`__init__.py:926` RuntimeContext),且 ADR-0010 #83 节明文背书过这个手法——#83 加 `/why_query` 时漏掉派发测试,正是「靠人记得」的失效实例。用注释去补一个注释没拦住的坑,是原方案内部最不一致的一处。 4. `pyproject.toml` 删 `nonebot-plugin-uninfo` 那行 + `uv lock`;并**补齐 AC 第 4 条欠的注释**(5 条依赖当前零注释)。 5. `admin.py` 的 `IsAdmin` docstring 补一段给 host 的指路(只写不会过期的边界陈述,见决策 2 更正 2)。 6. ADR:**按本仓惯例只追加 `## 更新(日期,issue #88)` 节,不就地改写既有「决策」/「拒绝了」**(ADR-0004 至今保留已被 ADR-0030 移除的 `poke` 位、ADR-0010 用「上一条更新已过时」推翻前文而不删前文——三条同形先例)。落点:ADR-0010 收口 #80 留下的两个悬挂项;ADR-0011 新增「命令解析层」选型条目(这一项的缺位正是 alconna 反复冒头的根因),**必须写明重评触发条件**(需要子命令/选项文法、或第三个手写解析器出现)+ **如实记录 alconna 在命令头边界上确有一项收益、我们用 `force_whitespace` 以低得多的代价拿到了它**。 **顺手的诚实性修补**(同源、低成本):`__init__.py` 那段「四个运行时管理命令」注释与其下 7 个 matcher 不符;`admin.py:4-5` 把「核心逻辑与调度驱动分离」当 ADR-0010 原话引用,而 ADR-0010 全文无此短语。 ## 仍需人类拍板(评估刻意不替你决定) 1. **路线图上是否已有需要子命令或多选项文法的管理命令规划?** 这是唯一能推翻「不迁移」的信息。(附带:即便真出现,`parse_why_query` 那 6+ 条 Alconna 表达不了的拒绝规则依然要后置校验,所以翻盘门槛比看上去还高。) 2. **`nonebot-plugin-alconna` 是彻底移出,还是保留但把注释改成诚实的「保留待用,当前 0 引用」?** 它不像 uninfo 那样构成边界违规(不拖任何平台适配器,uniseg 的 28 个 adapter 是惰性 Loader 桩),所以移除的理由**只是未用依赖卫生,强度弱于移除 uninfo**。注意两个 host 都已自行声明了 alconna 并在 `[tool.nonebot.plugins]` 里显式加载;另注意上面那条 `to_target()` 耦合。 3. **「处理逻辑可脱离真实事件派发直接测试」要不要从 #80 的 AC 升格为 CONTEXT.md/ADR-0010 明文约束?** 核实:三份权威产物里一个字都没有,只有「core 从一开始为可测而分层」这条通则做靠山,且明文枚举的四个对象不含管理命令。 4. **新口径落 ADR-0001 还是 ADR-0011?** 本 issue 的 AC 第 1 条点名的是 ADR-0001,但 ADR-0001 通篇讲接口/回调边界、一字不涉打包。评估倾向 ADR-0011(选型/依赖那份)——但这是对 AC 明文落点的一次偏离,按本仓「偏离 AC 明文需要单独拍板」的纪律,显式标出来请你定。
KumaAgent changed title from 命令层迁移到 Alconna + 清理声明了却未使用的依赖 to 命令头边界修复 + 清理零引用依赖 + 命令解析层选型收口(结论:不迁移 Alconna) 2026-07-29 02:45:10 +00:00
Yushu closed this issue 2026-07-29 03:44:38 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
ProjectKuma/arise#88
No description provided.