命令头边界修复 + 清理零引用依赖 + 命令解析层选型收口(结论:不迁移 Alconna) #88
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
一、pyproject 里两条零引用依赖
nonebot-plugin-alconna(pyproject.toml:11,注释「跨平台命令解析拓展」)与nonebot-plugin-uninfo(同处,注释「多平台用户信息获取」)都是已声明依赖,但全仓 0 个文件引用:uninfo更进一步:它硬依赖nonebot-adapter-qq(→ cryptography/cffi/pycparser),让每一个部署强装一个平台适配器——CONTEXT.md「core……平台无关」在打包层直接不成立。两个 host 都已自给自足,删掉今天零影响:
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 的注册):而
_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(走默认{"/"}),自然中文不带/前缀所以今天不可达;但这是一道零成本就能关掉的、后果涉及隐私授权的潜在闸。更正原正文的三处过期事实
/cost(#81)、/why//why_query(#83)已合并。原 Not in scope 那条「/cost//why//why_query各在自己的 ticket」已过期。__init__.py里那段注释自己也还写着「四个运行时管理命令」,其下挂着 7 个 matcher。parse_tool_toggle不是「约 20 行」,是 11 个代码行;真正的大头是 #83 引入的explain.parse_why_query(29 代码行)——手写解析器现在有两个。_argument_of不是「手写的命令解析」:它的函数体只有一行return command_arg.extract_plain_text().strip(),切分早已委托给 NoneBot 官方CommandArg()DI。原正文「issue #80 在 on_command 之上手写了一层命令解析」这个立论前提不成立——#80 那条 bug 在第二个 commit 就已经用CommandArg()DI 修掉了,core 侧现在没有任何「命令名到哪儿结束」的判断。三个决策的结论
决策 1:不迁移到
on_alconnaalconna_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。settool weather ON(parse_tool_toggledocstring 明写大小写不敏感是刻意的,AlconnaLiteral大小写敏感)、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) 路线)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,原正文把它排除在范围外是对的
Receipt.recallable是运行时探测,而CapabilitySetdocstring 逐字写着「不做运行时探测」;Receipt.recall的except NotImplementedError: pass是静默 no-op,撞「core 拥降级策略」+ ADR-0012 的 edit→recall+resend 链),且是EgressPort契约的破坏性变更。Acceptance criteria
命令头边界
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)。any-llm-sdk/httpx/nonebot-plugin-orm/nonebot2/qdrant-client五条一条注释都没有;localstore的「# 存储文件」没说它是 orm 的传递依赖(而那正是 ADR-0010 给它豁免的全部理由)。写注释前逐条git grep核实真实引用点,不要照抄旧意图。nonebot-plugin-*的话 4 条里触发 2 条,其中 localstore 是 ADR-0010 已豁免的必须进白名单——一上线就需要长期维护的例外表才能产出零信号。这条注释约定就是零维护的答案。admin.py的IsAdmindocstring 补一段给 host 的指路,只写不会过期的边界陈述:uninfo 的ADMIN()答的是「这人在这个群是不是管理员」,而IsAdmin问的是「这人能不能管这个机器人」——把前者当后者,就是替所有部署做了该 docstring 明确拒绝做的那个选择。.venv、不被任何测试触及,那份清单在本仓永远无人可验证。需要提覆盖率时用不点名的形状(「并非所有适配器都产出Role,host 需自行确认目标平台」)。文档
## 更新(2026-07-xx,issue #88)节,不就地改写既有「决策」/「拒绝了」——本仓惯例,三条实证:ADR-0004 的「决策」节至今保留已被 ADR-0030 宣布移除的poke位;ADR-0010 用「上一条更新(issue #41)记的『尚未实现』已过时」推翻前文而不删前文;ADR-0001 的 #80 更新同形。on_command+CommandArg()DI +force_whitespace=True。force_whitespace以低得多的代价拿到了它」。parse_tool_toggle11 行 +parse_why_query29 行(对比 8 张表全套 SQL 与迁移),且库表达不了既有 6+ 条拒绝断言、用了也得原样留后置校验。/settool list|add|rm <name>;「参数变多」不算,两个位置参数现有手写解析吃得下)。/costume)的根因都是测试网没覆盖,不是框架不够强。把出 bug 写成触发条件会诱导下一个人错误归因到框架,而真正该补的是测试网。require()的东西」——httpx与localstore同样零引用却必须保留(ADR-0010 已明文给 localstore 开过口子:「它是 orm 的传递依赖……显式声明传递依赖是合理做法」),那样写会让 ADR-0011 与 ADR-0010 当场矛盾,并制造一条「规则本身被它治理的仓库违反」的落差。_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_*—— 这些明确零改动。EgressPort契约零改动。nonebot-plugin-localstore与httpx:两者虽同样零引用,但都是合理的显式传递依赖声明(localstore ← orm,见 ADR-0010 已有裁定;httpx ← any-llm-sdk / qdrant-client)。只补注释说清,不删。src/零处 UniMessage」这类扫源码文本的守卫:本仓无此测试体裁先例,且今天恒真、易被无关注释打红;pyproject.toml的 ruffselect已经选上了TID,flake8-tidy-imports的banned-api是一行声明式禁令且严格更强(grep 拦不住import UniMessage as U)。若真要守,用它。Blocked by
无。PR #87(issue #80)已合并。
评估结论(2026-07-29)
三个决策全部有结论。评估方式:4 路并行侦察(introspect 实装 alconna 0.62.0 / uninfo 0.11.1、通读现有命令层与测试、逐字核对 ADR)→ 3 份带论据的建议 → 每份 2 个不同视角的对抗性复核(红队反驳 + ADR 协调性)。6 个复核全部判
SOUND_WITH_CAVEATS,无FLAWED,但红队推翻了一条承重论断,见下。先更正本 issue 正文自己的三处过期事实(先更正再讨论,否则 AC 会照着错的范围写):
/cost(#81)、/why//why_query(#83)已合并。正文 Not in scope 那条「/cost//why//why_query各在自己的 ticket」已过期。顺带:__init__.py里那段注释自己也还写着「四个运行时管理命令」,其下挂着 7 个 matcher。parse_tool_toggle不是「约 20 行」,是 11 个代码行;真正的大头是 #83 引入的explain.parse_why_query(29 代码行)——手写解析器现在有两个,不是一个。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 条注册):而
_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的判定链是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→ 不迁移理由:
_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。settool weather ON(parse_tool_toggledocstring 明写大小写不敏感是刻意的,AlconnaLiteral大小写敏感)、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大半原样留下 + 多一层解析。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 移出nonebot-adapter-qq→ cryptography/cffi/pycparser。一条 core 从不 import 的依赖,硬拖一个平台适配器进每一个部署——CONTEXT.md「core……平台无关」在打包层直接不成立。dxkuma-bot-qq早已自己声明了nonebot-plugin-uninfo>=0.11.0,dxkuma-bot-ob11的 uv.lock 里 arise 出现 0 次(尚未集成)。三条更正(已采纳):
require()的东西」写下去当天就被自己治理的仓库违反——httpx与localstore同样零引用,而两者都必须留(ADR-0010 已明文给 localstore 开过口子:「它是 orm 的传递依赖……显式声明传递依赖是合理做法」)。改用能真正区分的措辞:「不为『host 可能用得上』声明依赖,尤其不声明会拖平台适配器的依赖」——这样 httpx/localstore 自然留下、uninfo 自然出局,不需要维护白名单。Role」写进 core 的 docstring。移除依赖后 uninfo 不在 arise 的 lock、不在.venv、不被任何测试触及,这份版本绑定的清单在本仓永远无人可验证。保留那条不会过期的边界陈述即可:uninfo 的ADMIN()答的是「这人在这个群是不是管理员」,而IsAdmin问的是「这人能不能管这个机器人」——把前者当后者,就是替所有部署做了admin.pydocstring 明确拒绝做的那个选择。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 把它排除在范围外是对的
Receipt.recallable是运行时探测,而CapabilitySetdocstring 逐字写着「不做运行时探测」;Receipt.recall的except NotImplementedError: pass是静默 no-op,撞 ADR-0004「core 拥降级策略」+ ADR-0012 的 edit→recall+resend 链。EgressPort契约的破坏性变更(九个方法载荷全是裸标量)。三条更正(已采纳):
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 个模块」。src/零处 UniMessage」的守卫。 本仓没有任何扫源码文本的测试先例,这条今天恒真、且任何在ports.pydocstring 里解释「本层刻意不用 UniMessage」的注释都会把它打红。而pyproject.toml的 ruffselect已经选上了TID——flake8-tidy-imports的banned-api是一行声明式禁令,且严格更强(grep 拦不住import UniMessage as U,禁 import 路径拦得住)。结论:本 ticket 应该做什么
范围从「迁移 + 清依赖」收缩为「关掉命令头边界缺陷 + 清一条依赖 + 把选型收口进 ADR」:
on_command全部加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 条是冗余。test_cost.pyTriggerPath、test_breaker.pyBreakerState、test_explain.py人话表、__init__.py:926RuntimeContext),且 ADR-0010 #83 节明文背书过这个手法——#83 加/why_query时漏掉派发测试,正是「靠人记得」的失效实例。用注释去补一个注释没拦住的坑,是原方案内部最不一致的一处。pyproject.toml删nonebot-plugin-uninfo那行 +uv lock;并补齐 AC 第 4 条欠的注释(5 条依赖当前零注释)。admin.py的IsAdmindocstring 补一段给 host 的指路(只写不会过期的边界陈述,见决策 2 更正 2)。## 更新(日期,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 全文无此短语。仍需人类拍板(评估刻意不替你决定)
parse_why_query那 6+ 条 Alconna 表达不了的拒绝规则依然要后置校验,所以翻盘门槛比看上去还高。)nonebot-plugin-alconna是彻底移出,还是保留但把注释改成诚实的「保留待用,当前 0 引用」? 它不像 uninfo 那样构成边界违规(不拖任何平台适配器,uniseg 的 28 个 adapter 是惰性 Loader 桩),所以移除的理由只是未用依赖卫生,强度弱于移除 uninfo。注意两个 host 都已自行声明了 alconna 并在[tool.nonebot.plugins]里显式加载;另注意上面那条to_target()耦合。命令层迁移到 Alconna + 清理声明了却未使用的依赖to 命令头边界修复 + 清理零引用依赖 + 命令解析层选型收口(结论:不迁移 Alconna)