koishijs神秘bug (其实是bare-trigger插件实现

1720 字
9 分钟
koishijs神秘bug (其实是bare-trigger插件实现

我用 koishi 搭 QQ bot,QQ 号和 AstrBot、Luna 共享一个 NapCat(v4.18.9)实例,三个 bot 通过不同的 reverse WebSocket 接 NapCat:

  • AstrBot:ws://127.0.0.1:8997
  • Luna (NoneBot2):ws://127.0.0.1:6199
  • Koishi:ws://127.0.0.1:6700 music-voice 插件的命令名是 163,私聊发 163 wannacry 能正常返回搜索结果,群聊发同样的消息却完全没反应。NapCat 日志清楚显示 koishi 收不到任何 发送 -> 输出,但群里的 help 又能正常响应,这个差异让我意识到是它根本没把群消息当指令

koishi 默认群里要 prefix 或 @bot 本体#

翻 koishi core(@koishijs/core/lib/index.cjs:1275):

ctx.before("attach", (session) => {
const { hasAt, appel } = session.stripped;
if (!appel && hasAt) return;
let content = session.stripped.content;
for (const prefix of this._resolvePrefixes(session)) {
.tina-code-block .hljs-comment,
.tina-code-block .hljs-code,
.tina-code-block .hljs-formula { color: #6a737d; }
.tina-code-block .hljs-keyword,
.tina-code-block .hljs-doctag,
.tina-code-block .hljs-template-tag,
.tina-code-block .hljs-template-variable,
.tina-code-block .hljs-type,
.tina-code-block .hljs-variable.language_ { color: #d73a49; }
.tina-code-block .hljs-title,
.tina-code-block .hljs-title.class_,
.tina-code-block .hljs-title.class_.inherited__,
.tina-code-block .hljs-title.function_ { color: #6f42c1; }
.tina-code-block .hljs-attr,
.tina-code-block .hljs-attribute,
.tina-code-block .hljs-literal,
.tina-code-block .hljs-meta,
.tina-code-block .hljs-number,
.tina-code-block .hljs-operator,
.tina-code-block .hljs-selector-attr,
.tina-code-block .hljs-selector-class,
.tina-code-block .hljs-selector-id,
.tina-code-block .hljs-variable { color: #005cc5; }
.tina-code-block .hljs-regexp,
.tina-code-block .hljs-string,
.tina-code-block .hljs-meta_.hljs-string { color: #0366d6; }
.tina-code-block .hljs-built_in,
.tina-code-block .hljs-symbol { color: #e36209; }
.tina-code-block .hljs-name,
.tina-code-block .hljs-quote,
.tina-code-block .hljs-selector-tag,
.tina-code-block .hljs-selector-pseudo { color: #22863a; }
.tina-code-block .hljs-emphasis { font-style: italic; }
.tina-code-block .hljs-strong { font-weight: bold; }
.tina-code-block .hljs-section { font-weight: bold; color: #005cc5; }
.tina-code-block .hljs-bullet { color: #735c0f; }
.tina-code-block .hljs-addition { background: #f0fff4; color: #22863a; }
.tina-code-block .hljs-deletion { background: #ffeef0; color: #b31d28; }
.slate-code_line > span:last-child {margin-right: 1rem;}
if (!content.startsWith(prefix)) continue;
session.stripped.prefix = prefix;
content = content.slice(prefix.length);
break;
Plain Text
}
...
});

紧接着是 inferCommandindex.cjs:1446):

inferCommand(argv) {
if (!argv) return;
if (argv.command) return argv.command;
...
const isStrict = this.config.prefixMode === "strict" || !isDirect && !stripped.appel;
if (argv.root && stripped.prefix === null && isStrict) return; // ← 群里裸消息栽在这
...
}

逻辑链是这样的:

场景isDirectstripped.prefixstripped.appelinferCommand 行为
私聊裸 163truenullfalse直接走 token 匹配 → ✅
群聊裸 163falsenullfalseisStrict=true && prefix===null → return → ❌
群聊 /163false'/'falseprefix 已经被 attach 剥过 → ✅
群聊 @bot 163falsenulltrueappel=true 短路掉 isStrict → ✅
help 在群里能用,是因为 plugin-help 用了 cmd.shortcut('help', { fuzzy: true }),注册到了 koishi 的 matcher 池里,绕开inferCommand 的 prefix 判定 music-voice 这种用 ctx.command('163 ...') 正常注册的命令,必须走 inferCommand,于是踩坑
我先把 koishi.yml 顶层的 prefix: [] 改成 prefix: '' / prefix: [''],想给所有消息加个”空前缀”——配置加载确实生效了(commander.config.prefix = [""]),但 stripped.prefix 依然是 null

attach hook 在多 ctx 下被 filter 掉了#

koishi 用的是 cordis 作为 hook 框架,每个 plugin 都活在独立 ctx scope 里。Commander 是在 koishi app 启动时(provide("$commander", new Commander(this, ...)))注册 hook,hook 的 ctx 落在 root scope 上 但 session(每个 message event 的 thisArg)是从 adapter-onebot 来的,session 的 ctx 落在 adapter 的 ctx scope 下。 cordis 的 Lifecycle.filterHooks@cordisjs/core/lib/index.cjs:500):

filterHooks(hooks, thisArg) {
thisArg = getTraceable(this.ctx, thisArg);
return hooks.slice().filter((hook) => {
.tina-code-block .hljs-comment,
.tina-code-block .hljs-code,
.tina-code-block .hljs-formula { color: #6a737d; }
.tina-code-block .hljs-keyword,
.tina-code-block .hljs-doctag,
.tina-code-block .hljs-template-tag,
.tina-code-block .hljs-template-variable,
.tina-code-block .hljs-type,
.tina-code-block .hljs-variable.language_ { color: #d73a49; }
.tina-code-block .hljs-title,
.tina-code-block .hljs-title.class_,
.tina-code-block .hljs-title.class_.inherited__,
.tina-code-block .hljs-title.function_ { color: #6f42c1; }
.tina-code-block .hljs-attr,
.tina-code-block .hljs-attribute,
.tina-code-block .hljs-literal,
.tina-code-block .hljs-meta,
.tina-code-block .hljs-number,
.tina-code-block .hljs-operator,
.tina-code-block .hljs-selector-attr,
.tina-code-block .hljs-selector-class,
.tina-code-block .hljs-selector-id,
.tina-code-block .hljs-variable { color: #005cc5; }
.tina-code-block .hljs-regexp,
.tina-code-block .hljs-string,
.tina-code-block .hljs-meta_.hljs-string { color: #0366d6; }
.tina-code-block .hljs-built_in,
.tina-code-block .hljs-symbol { color: #e36209; }
.tina-code-block .hljs-name,
.tina-code-block .hljs-quote,
.tina-code-block .hljs-selector-tag,
.tina-code-block .hljs-selector-pseudo { color: #22863a; }
.tina-code-block .hljs-emphasis { font-style: italic; }
.tina-code-block .hljs-strong { font-weight: bold; }
.tina-code-block .hljs-section { font-weight: bold; color: #005cc5; }
.tina-code-block .hljs-bullet { color: #735c0f; }
.tina-code-block .hljs-addition { background: #f0fff4; color: #22863a; }
.tina-code-block .hljs-deletion { background: #ffeef0; color: #b31d28; }
.slate-code_line > span:last-child {margin-right: 1rem;}
const filter = thisArg?.\[Context.filter];
return hook.global || !filter || filter.call(thisArg, hook.ctx);
Plain Text
});
}

thisArg(session)带 Context.filter 时,hook.ctx 必须 落在 session ctx 的可达 scope 里才会执行。我直接 patch @koishijs/core/lib/index.cjsCommander 的 attach hook 里塞 console.log,启动 koishi、群聊发消息——日志一条都没打出来。但我自己写的 plugin 注册的 ctx.before('attach', ...) 在同一条消息上能触发 也就是说:Commander 的 attach hook 在群消息路径上从来没被调用过。这就把 stripped.prefix 一直留在初始的 null,后续所有逻辑都被短路 koishi 1.0 时代 core 自己直接 root 注册 hook 应该没问题,2.x/4.x 把 ctx 隔离做细之后,这个 hook 的注册 scope 看起来需要被显式 patch 上去。社区里搜到的所有”群消息不响应”issue,最后都是用户加 prefix 或 @bot 凑合过去,没人去诊断 filterHooks 的细节

解决方法是写一个本地插件做一个类似裸触发器的东西 dispatch 掉它#

绕开 cordis 的 filter 链最干净的做法:在 plugin 自己的 ctx 里直接接管 message 分发,不依赖 koishi core 的 attach/inferCommand 流程 /opt/koishi-app/plugins/koishi-plugin-bare-trigger/package.json

{
"name": "koishi-plugin-bare-trigger",
"version": "0.0.1",
"main": "index.js",
"koishi": {
.tina-code-block .hljs-comment,
.tina-code-block .hljs-code,
.tina-code-block .hljs-formula { color: #6a737d; }
.tina-code-block .hljs-keyword,
.tina-code-block .hljs-doctag,
.tina-code-block .hljs-template-tag,
.tina-code-block .hljs-template-variable,
.tina-code-block .hljs-type,
.tina-code-block .hljs-variable.language_ { color: #d73a49; }
.tina-code-block .hljs-title,
.tina-code-block .hljs-title.class_,
.tina-code-block .hljs-title.class_.inherited__,
.tina-code-block .hljs-title.function_ { color: #6f42c1; }
.tina-code-block .hljs-attr,
.tina-code-block .hljs-attribute,
.tina-code-block .hljs-literal,
.tina-code-block .hljs-meta,
.tina-code-block .hljs-number,
.tina-code-block .hljs-operator,
.tina-code-block .hljs-selector-attr,
.tina-code-block .hljs-selector-class,
.tina-code-block .hljs-selector-id,
.tina-code-block .hljs-variable { color: #005cc5; }
.tina-code-block .hljs-regexp,
.tina-code-block .hljs-string,
.tina-code-block .hljs-meta_.hljs-string { color: #0366d6; }
.tina-code-block .hljs-built_in,
.tina-code-block .hljs-symbol { color: #e36209; }
.tina-code-block .hljs-name,
.tina-code-block .hljs-quote,
.tina-code-block .hljs-selector-tag,
.tina-code-block .hljs-selector-pseudo { color: #22863a; }
.tina-code-block .hljs-emphasis { font-style: italic; }
.tina-code-block .hljs-strong { font-weight: bold; }
.tina-code-block .hljs-section { font-weight: bold; color: #005cc5; }
.tina-code-block .hljs-bullet { color: #735c0f; }
.tina-code-block .hljs-addition { background: #f0fff4; color: #22863a; }
.tina-code-block .hljs-deletion { background: #ffeef0; color: #b31d28; }
.slate-code_line > span:last-child {margin-right: 1rem;}
"description": { "zh": "让群消息像私聊一样裸触发 koishi 已注册命令" }
Plain Text
}
}

/opt/koishi-app/plugins/koishi-plugin-bare-trigger/index.js

const k = require('@koishijs/core')
module.exports = {
apply(ctx) {
.tina-code-block .hljs-comment,
.tina-code-block .hljs-code,
.tina-code-block .hljs-formula { color: #6a737d; }
.tina-code-block .hljs-keyword,
.tina-code-block .hljs-doctag,
.tina-code-block .hljs-template-tag,
.tina-code-block .hljs-template-variable,
.tina-code-block .hljs-type,
.tina-code-block .hljs-variable.language_ { color: #d73a49; }
.tina-code-block .hljs-title,
.tina-code-block .hljs-title.class_,
.tina-code-block .hljs-title.class_.inherited__,
.tina-code-block .hljs-title.function_ { color: #6f42c1; }
.tina-code-block .hljs-attr,
.tina-code-block .hljs-attribute,
.tina-code-block .hljs-literal,
.tina-code-block .hljs-meta,
.tina-code-block .hljs-number,
.tina-code-block .hljs-operator,
.tina-code-block .hljs-selector-attr,
.tina-code-block .hljs-selector-class,
.tina-code-block .hljs-selector-id,
.tina-code-block .hljs-variable { color: #005cc5; }
.tina-code-block .hljs-regexp,
.tina-code-block .hljs-string,
.tina-code-block .hljs-meta_.hljs-string { color: #0366d6; }
.tina-code-block .hljs-built_in,
.tina-code-block .hljs-symbol { color: #e36209; }
.tina-code-block .hljs-name,
.tina-code-block .hljs-quote,
.tina-code-block .hljs-selector-tag,
.tina-code-block .hljs-selector-pseudo { color: #22863a; }
.tina-code-block .hljs-emphasis { font-style: italic; }
.tina-code-block .hljs-strong { font-weight: bold; }
.tina-code-block .hljs-section { font-weight: bold; color: #005cc5; }
.tina-code-block .hljs-bullet { color: #735c0f; }
.tina-code-block .hljs-addition { background: #f0fff4; color: #22863a; }
.tina-code-block .hljs-deletion { background: #ffeef0; color: #b31d28; }
.slate-code_line > span:last-child {margin-right: 1rem;}
ctx.on('message', async (session) => {
//私聊不需要这个插件(私聊本身就能裸触发)
if (session.stripped?.isDirect) return
//已经有 prefix 或 @bot,走 koishi 自带路径
if (session.stripped?.prefix !== null) return
if (session.stripped?.hasAt || session.stripped?.appel) return
let content = session.stripped?.content
if (!content) return
if (content.startsWith('/')) content = content.slice(1)
const at = content.match(/^@\S+\s+/)
if (at) content = content.slice(at\[0].length)
const first = content.split(/\s+/, 1)\[0]
if (!first) return
if (!ctx.app.$commander.get(first, session)) return
try {
//关键:把 prefix 临时设为空字符串,
//否则 inferCommand 里的 "stripped.prefix === null && isStrict" 会 return
session.stripped.prefix = ''
const argv = k.Argv.parse(content)
argv.session = session
argv.root = true
ctx.app.$commander.inferCommand(argv)
await session.execute(argv)
} catch {}
})
Plain Text
}
}

symlink + koishi.yml 注册

Terminal window
ln -s ../plugins/koishi-plugin-bare-trigger /opt/koishi-app/node_modules/koishi-plugin-bare-trigger

koishi.yml 里在 adapter-onebot 后面加一行:

adapter-onebot:8fxdaw:
selfId: '32******38'
protocol: ws
endpoint: ws://127.0.0.1:6700
token: '***'
bare-trigger:bare01: {}

重启 koishi:

Terminal window
systemctl restart koishi-app.service

效果#

napcat 日志里看到:

08:04:45 [info] OwO | 接收 <- 群聊 [...rin's lab...] [Rin] 163 1
08:04:46 [info] OwO | 发送 -> 群聊 [...rin's lab...] [OwO(32******38)] [图片] 退出选择请发送 [0, 不听了] 中的任意内容 ...

群消息裸触发 100% 跟私聊一致,私聊行为不变,已有 prefix / @bot 的命令也不冲突(plugin 在第一步 return 掉了)。

后话#

回过头看,prefix: [''] 这个修复其实方向是对的,但被 koishi core attach hook 没触发的事实掩盖了——配置生效了但路径走不到。如果哪天 koishi 修了 attach hook 的 scope 问题,这条 prefix: [''] 也能独立工作。但裸触发这个用例,最好的实现还是 plugin:它更显式 更可控 不依赖 koishi 内部的 hook 拓扑 如果你也遇到群里自定义命令不响应的问题,可以照上面三步走 (o´・ω・)

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

koishijs神秘bug (其实是bare-trigger插件实现
https://blog.rin.red/posts/koishijsbug-bare-trigger/
作者
rin
发布于
2026-07-31
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
rin
DevOps / 自托管 / AI Infra。
公告
欢迎来到 Rin's Blog。若有链接失效或显示异常欢迎留言。
音乐
封面

音乐

暂未播放

0:000:00
暂无歌词
分类
标签
站点统计
文章
15
分类
2
标签
24
总字数
11,418
运行时长
0
最后活动
0 天前
站点信息
构建平台
GitHub Actions
博客版本
Firefly v6.13.5
文章许可
CC BY-NC-SA 4.0

文章目录