外观
故障排查
连接被拒绝 / 一直连不上(ECONNREFUSED 等)
- 确认 SnowLuma 已经启动并正在监听
wsUrl指向的地址和端口。 - 跨主机或容器部署时,检查网络可达性与防火墙规则。
- 确认
wsUrl的协议、主机、端口都正确——注意ws://对应wsUrl,http://对应httpUrl,是两个不同的配置项,不要混用(httpUrl只影响 Agent 工具/一次性 action 走 HTTP 还是复用网关的 WebSocket,网关本身的长连接始终通过wsUrl建立,见src/gateway.ts的startGateway——httpUrl缺失完全不影响网关能否启动)。 - 底层连接由
@snowluma/sdk的SnowLumaWebSocketClient负责建立,重连行为受reconnect.*配置调优(见配置参考 · reconnect)——如果reconnect.enabled: false或者reconnect.retries被显式设成了较小的数字,断线后可能不会无限重试,网关会停在断开状态直到手动重启。
token 被拒绝(SnowLumaAuthError)
SnowLumaAuthError 是 @snowluma/sdk 自己抛出的异常类型(SnowLumaApiError 的子类),代表 SnowLuma 返回了鉴权/授权失败的响应。
accessToken必须和 SnowLuma 侧配置的 token 完全一致——可以在 SnowLuma 自身的config/onebot_<uin>.json里核对。- 如果 SnowLuma 开启了鉴权、但插件没有配置
accessToken(或者反过来,SnowLuma 未启用鉴权但插件配置了一个 token),连接建立或后续的 action 调用都会被拒绝。 - 记得检查
default账号是否误依赖了环境变量回退(SNOWLUMA_ACCESS_TOKEN/SNOWLUMA_TOKEN)却没有正确设置,或者命名账号(channels.snowluma.accounts.<id>)忘了配置accessToken——命名账号不会回退到环境变量,必须显式配置。
mention 从来不触发
按下面的顺序排查(对应 src/triggers.ts 的 evaluateTrigger 判定顺序,见三种接收模式 · mention):
- 先确认
receive.mention.enabled没有被设为false——这是绝对覆盖,为false时任何条件都不会触发这个模式。 - 群聊
@触发依赖插件知道机器人自己的 QQ 号(selfId)。evaluateTrigger的提及判定是msg.mentions.includes(String(account.selfId)),如果account.selfId是undefined,这条规则永远不成立,哪怕消息确实@了机器人。selfId的来源有两个:显式配置,或者网关启动阶段调用get_login_info自动探测。检查网关启动日志:- 正常应该看到
[snowluma:<accountId>] gateway ready (selfId=<数字>); - 如果看到
could not determine the bot's own QQ id,说明自动探测失败了(网络问题、token 错误等),此时最稳妥的做法是在配置里显式写selfId(或对应的SNOWLUMA_SELF_ID环境变量,仅对default账号生效)。
- 正常应该看到
- 确认没有误用
@全体成员——atAll从不参与提及判定(详见三种接收模式),@全体成员不等于@机器人。 - 群聊里如果没有
@,检查是否命中了其他触发路径:回复机器人自己的消息(triggerOnReplyToSelf)、关键词(keywords/keywordMatch/caseSensitive)——如果都没命中,且requireMentionInGroup: true(默认),这条消息本来就不该触发,这是预期行为而不是故障。 - 私聊消息没反应,检查
alwaysReplyInDirect是否被设为了false——关掉之后私聊也需要命中关键词才会触发。
digest 从来不触发
- 确认
receive.digest.enabled为true——这是三个模式里唯一默认关闭的。 - 确认目标聊天落在
scope("group"/"direct"/"all")与peers白名单范围内(peers为空表示scope内全部观察,不是"不限制到不检查 scope",两个条件是 AND 关系)。 - 最容易踩的坑是
minMessages:intervalMs到期时,如果缓冲的消息数还没达到minMessages,flush 会被持续抑制——窗口不会清空,只会不断重新排下一个intervalMs周期的计时器,行为上看起来就是"从来不触发",但其实是在正常地"抑制-重试"(详见三种接收模式 · digest)。适当调低minMessages,或确认这个聊天的活跃度确实能在一个intervalMs周期内产生minMessages条消息。 - 确认这个聊天没有被
allowFrom/denyFrom挡在门外——isPeerAllowed检查发生在evaluateTrigger/aggregator.accept之前,被拒绝的来源连 digest 缓冲区都进不去。 - 如果窗口确实 flush 了,但群里什么都没收到:检查 Agent 的回复是不是就是
SKIP——比较在 Markdown 拍平之后做,大小写不敏感,并会剥掉标题/列表这层装饰,所以**SKIP**、## SKIP、- skip一律算作 SKIP。这种情况下插件按设计不会发送任何消息,属于正常路径而非故障。
/summary 没反应
- 命令词必须在消息开头(前导
@机器人会被自动剥掉)。帮我 /summary 一下不匹配,/summarylater也不匹配——命令词后面只能是消息结尾、空白或数字。 - 确认
receive.summary.enabled为true,以及该聊天落在scope/peers范围内。 - 确认这个聊天没被
allowFrom/denyFrom挡住——这道检查在命令匹配之前。 - 如果收到的是「获取最近聊天记录失败」或「最近没有可以总结的聊天记录」,说明命令已经生效,问题出在 SnowLuma 的历史消息接口:确认后端实现了
get_group_msg_history/get_friend_msg_history,且机器人在该群里有读取权限。
升级到 0.5.0:render 配置项已移除
0.4.x 会把 digest / /summary 的总结渲染成 PNG 发送,0.5.0 起改为发送拍平后的纯文本(规则见接收模式 · 回复以纯文本发出),marked / satori / @resvg/resvg-wasm 三个依赖一并去掉。
因此 channels.snowluma.render(以及 accounts.<id>.render)整块不再有任何作用:
- 运行时会直接忽略它,不会报错、不会影响插件加载;
- 但账号配置的 JSON Schema 声明了
additionalProperties: false,控制台的配置编辑器会把它标成未知字段。
升级后把 render 整段从配置里删掉即可,没有替代项需要填。
ERR_MODULE_NOT_FOUND,报错路径指向 @snowluma/sdk
说明加载到了未打补丁的 @snowluma/sdk(背景原理见快速开始 · @snowluma/sdk ESM 补丁说明)。
关键事实:OpenClaw 的插件安装器执行 npm install 时硬编码了 --ignore-scripts(还在环境里设了 NPM_CONFIG_IGNORE_SCRIPTS=true),所以走 openclaw plugins install 安装时,本插件的 postinstall 钩子从来不会执行——指望安装期补丁在网关上是行不通的。
0.1.4起插件在加载时自动自我修补:src/sdk.ts会在第一次真正使用 SDK 之前(网关启动 / 工具借用客户端时)先原地重写@snowluma/sdk/dist里的坏说明符,再动态import它。网关日志里会出现一行[snowluma] patched N extensionless import(s) ...,属于正常现象。在网关上看到本错误 ⇒ 插件版本 < 0.1.4,升级即可。- 手动
npm install场景(不经过 OpenClaw CLI):postinstall正常时无需干预;如果用了npm ci --ignore-scripts或手动拷贝了node_modules,0.1.4之前需要手动补一次:
bash
node ./scripts/patch-snowluma-sdk.mjs这个脚本是幂等的,重复运行安全无副作用;0.1.4 起它只是手动安装流程的锦上添花,加载期自愈不依赖它。
ERR_REQUIRE_ESM_RACE_CONDITION,插件加载失败
典型日志(即使完整重启网关也会复现):
text
[plugins] openclaw-snowluma failed to load ...: Error [ERR_REQUIRE_ESM_RACE_CONDITION]:
Cannot require() ES Module .../openclaw/dist/plugin-sdk/core.js because it is not yet fully loaded.
This may be caused by a race condition if the module is simultaneously dynamically import()-ed via Promise.all().
... (From .../dist/setup-entry.js in non-loader-hook thread)这是本插件 <= 0.1.1 的一个真实加载缺陷,已在 0.1.2 彻底修复。 直接的处理方式是升级:
bash
openclaw plugins install openclaw-snowluma@latest # 或指定 openclaw-snowluma@0.1.2
openclaw gateway restart升级后不再需要任何绕行;旧版上无论怎么重启都无法绕开(更早的文档曾建议"完整重启即可",这是不准确的,特此更正)。
根因
OpenClaw 的插件加载器会同步 require() 插件的两个入口(dist/setup-entry.js 读 setup surface、dist/index.js 读 extensions),与此同时又在 loader-hook 线程上异步 import() 它们。在旧版里,入口的模块图引用了 openclaw/plugin-sdk/* 运行时模块(setup-entry → channel → tools → openclaw/plugin-sdk/core、→ gateway → dispatch → runtime → openclaw/plugin-sdk/runtime-store,以及 index → openclaw/plugin-sdk/core 的 defineChannelPluginEntry)。当同步 require() 走到某个正被异步 import() 求值到一半的 openclaw 模块时,Node 22+ 的 require(ESM) 就抛出 ERR_REQUIRE_ESM_RACE_CONDITION,整个插件加载失败。
0.1.1只修了setup-entry.js那一条链,于是同样的竞态转移到了index.js(报错里From ... dist/index.js)。0.1.2把两个入口都修掉了。
修复方式(0.1.2)
让两个被同步 require() 的入口模块图都不再引用任何 openclaw/* 运行时模块,同步加载便无从与异步 import() 竞争。为此把入口图里仅有的几处 openclaw 运行时导入都替换成了本地等价实现——它们对应的 SDK helper 都很薄,逐一核对过 SDK 源码:
setup-entry.ts内联defineSetupPluginEntry(该 helper 本就只返回{ plugin });src/plugin-entry.ts本地实现defineChannelPluginEntry(含其默认的emptyChannelConfigSchema),index.ts改用它,替掉对openclaw/plugin-sdk/core的引用;src/params.ts本地实现readNumberParam/readStringParam,src/tools.ts改用它;src/runtime.ts本地实现运行时 store(等价于 SDKcreatePluginRuntimeStore("...")字符串重载的模块级闭包),替掉对openclaw/plugin-sdk/runtime-store的引用。
结果是整个编译产物没有任何 openclaw/* 运行时导入——插件只通过运行时传入的 api / ctx 对象和 import type(编译期擦除)与宿主交互。test/load-graph.test.ts 有一条结构性回归测试,会在任何人再把 openclaw/* 运行时导入引入 index.js 或 setup-entry.js 的模块图时失败。
Cannot find module 'typebox'(或其它运行时依赖)加载失败
text
[plugins] openclaw-snowluma failed to load ...: Error: Cannot find module 'typebox'
Require stack:
- .../node_modules/openclaw-snowluma/dist/src/tools.js历史脉络与最终修复:
<= 0.1.2:tools.ts在模块加载时用typebox的Type.*构建工具参数 schema,但typebox被误放在devDependencies里——安装插件时不会装 devDependencies,于是加载dist/src/tools.js时报本错误。0.1.3:把typebox挪进dependencies。这只在网关全新安装依赖时有效——实践中发现 OpenClaw 可能复用已有的 generation 安装目录(日志里插件路径...__openclaw-generation__g-<hash>的哈希在多次重装后保持不变即是信号),旧目录里的依赖不会因为新 manifest 而补装,错误于是"反复出现"。0.1.4起从根上解决:插件运行时不再依赖typebox。 工具参数 schema 改为纯 JSON Schema 字面量(与 typebox 1.xType.Object(...)的产物逐字节一致,typebox 只作为类型引用保留在 devDependencies)。整个插件的运行时外部依赖只剩@snowluma/sdk一个,而它也是延迟动态加载的(见上一节)——网关怎么装依赖都不会再触发这一类错误。
升级时务必彻底卸载后重装,避免网关继续复用旧的 generation 目录:
bash
openclaw plugins uninstall openclaw-snowluma
# 确认旧安装目录已清理(应无输出):
ls -d ~/.openclaw/npm/projects/openclaw-snowluma__* 2>/dev/null
openclaw plugins install openclaw-snowluma@latest # >= 0.1.4
openclaw plugins enable openclaw-snowluma
openclaw gateway restart如何开启调试日志
插件本身不提供独立的调试开关(比如某个 SNOWLUMA_DEBUG 环境变量)——它接受宿主 OpenClaw Gateway 通过 ctx.log 注入的日志器(info/error/debug 三个可选方法),但通读 src/*.ts 会发现插件目前只调用 log?.info?.(...) 和 log?.error?.(...),从不调用 log?.debug?.(...)——也就是说插件目前没有区分"调试级"和"信息级"日志,能看到的所有插件日志都会出现在 info/error 级别,调整宿主自身的日志级别设置不会让插件"吐出更多"信息。
排查时最有用的是抓取带 [snowluma...] 前缀的日志行——按模块划分:
| 前缀 | 来源模块 | 典型内容 |
|---|---|---|
[snowluma:<accountId>] starting gateway / socket open / socket closed / gateway ready (selfId=...) | src/gateway.ts / src/channel.ts | 网关生命周期、连接状态、selfId 探测结果 |
[snowluma:<accountId>] message handling failed: ... | src/gateway.ts | 单条入站消息处理过程中的异常(已被捕获,不会中断网关) |
[snowluma:<accountId>] dispatch failed: ... / dispatch error: ... | src/gateway.ts / src/dispatch.ts | 一次 Agent 调用批次处理失败 |
[snowluma:<accountId>] quote resolution failed: ... | src/dispatch.ts | 引用/转发解析失败(已降级为占位符,不影响本次回复) |
[snowluma:<accountId>] send failed: ... / media send failed: ... | src/dispatch.ts | 回复发送失败 |
[snowluma] getMessage(...) failed: ... / getForwardMessage(...) failed: ... | src/quote.ts | 单次 get_msg/get_forward_msg 调用失败(模块级日志,不带 accountId) |
[snowluma] realtime accept failed: ... / digest accept failed: ... | src/aggregator.ts | 聚合引擎内部异常(同样是模块级前缀) |
这些错误日志本身就是设计上的"降级路径"证据——本插件几乎所有的失败都被有意捕获并记录成一条日志,而不是让异常向上传播中断进程,所以出问题时先看日志、而不是等进程崩溃,是最快的排查方式。