背景
我维护的公众号文章流水线一直靠 cron 轮询驱动。每 2 分钟扫描一次 ntfy 主题,判断有没有新消息。够用,但明显粗糙——大部分轮询是空跑,消息到达后要等最长 2 分钟才能触发。随着接入的自动化场景增多(青龙面板任务、Home Assistant 设备控制、通知转发),这个方案越来越捉襟见肘。
需要改成实时触发。但生态太碎片化:ntfy 原生支持 WebSocket,n8n 有社区节点,怎么把它们串起来又不引入复杂的外部依赖?
方案
核心思路是三个组件串成一条链:ntfy 做消息总线,n8n 做工作流引擎,WebSocket 做实时通道。
ntfy.sh 提供免费的消息队列服务,支持 HTTP POST 发送、WebSocket 实时订阅、JSON 格式解析。n8n 的社区节点 n8n-nodes-websockets-trigger 可以原生连接 WebSocket 端点,不需要中间翻译层。
统一 Hook 规范
为了让消息自描述、好路由,设计了一套轻量的 Hook 格式:
{
"target": "hermes",
"hook": "gzh-article/create",
"id": "evt-xxxxx",
"payload": { "title": "文章标题", "record_id": "rec_xxx" }
}
target决定消息去哪个子系统(hermes、qinglong、homeassistant 等)hook是具体动作(create、cancel、ping、task-run)id保证幂等,下游可以按 ID 去重
ntfy 主题用 rhook- 前缀 + 16 位随机编码防撞。同一个主题下所有子系统共享消息通道,由 n8n 工作流里的 IF 节点分拣。

实施

分四步走。
1. 安装 WebSocket 社区节点
# 进入 n8n 容器
docker exec -it n8n sh
# 安装
npm install n8n-nodes-websockets-trigger@latest
npm install ws@^8.18.0
# 重启容器加载新节点
docker restart n8n
也可以用 docker-compose 把安装写到构建步骤里。
2. 创建工作流(9 节点)
WebSocket Trigger (wss://ntfy.sh/rhook-xxx/ws)
│
Code 节点(校验 schema + 提取 target + hook)
│
IF(target = hermes?)
├─ true → IF(hook = create?)
│ ├─ true → SSH(create-pipeline.py)
│ └─ false → IF(hook = ping?)
│ ├─ true → Pong 响应
│ └─ false → 未匹配 hook
└─ false → 待扩展的其他 target
WebSocket Trigger 节点的关键配置:
| 参数 | 值 |
|---|---|
| URL | wss://ntfy.sh/你的主题/ws |
| Options | { "json": true } |
Code 节点用 jsCode 参数存储 JavaScript 逻辑,不要传 language 或 code(n8n 2.x 的坑)。
IF 节点替代了 Switch V2,原因是后者在 n8n 2.19 上需要额外配置 mode: "rules" 参数才能正常工作,IF 节点开箱即用。
3. 处理 SSH 的 non-interactive shell 问题
n8n 的 SSH 节点默认使用 non-interactive shell,不加载 .bashrc 或 .bash_profile。这意味着你装在用户目录下的 CLI 工具(比如 hermes、pip 的 --user 安装)都不在 PATH 里。
解决方案是用 bash -l -c 强制加载 login shell:
const shellEscape = (s) => "'" + s.replace(/'/g, "'\\''") + "'";
const sshCmd = "bash -l -c " + shellEscape("cd /project && python3 scripts/deploy.py ...");
4. 激活工作流
curl -X POST https://your-n8n-host:5678/api/v1/workflows/{id}/activate \
-H "X-N8N-API-KEY: your-api-key"
激活后,ntfy 主题收到消息,WebSocket 通道在毫秒级内推送到 n8n,经过 Code 节点校验→IF 分拣→SSH 执行,端到端延迟取决于 SSH 连接的建立时间。

踩坑记录
SSH PATH 问题
这是排查最久的。症状:SSH 节点执行脚本时报 FileNotFoundError: hermes,但手动 SSH 登录后执行完全正常。原因就是 non-interactive shell 不加载 .bashrc。统一用 bash -l -c 包装所有 SSH 命令即可解决。
Code 节点参数名
n8n 工作流 API 创建 Code 节点时,传 language: "javascript" 会导致 Unsupported language: javascript 报错。n8n 2.x 的 Code 节点只用 jsCode 参数存代码,把其他参数去掉就好了。
Switch V2 vs IF
Switch V2 在 2.19 版本上需要明确传 mode: "rules" 参数,否则报 output 4 not allowed。如果只有两个分支,直接用 IF 节点更省事。
重复触发
这是设计层面的坑。cron 轮询脚本中曾有一个字符串比较的 bug("skAuWM6PHQNO" > "TfVFwc5C7TaU" 按 ASCII 首字母比较),导致消息指针永远不前进,每 2 分钟重复处理同一条消息,产生 8 份重复流水线。改用 Unix 时间戳比较后修复。
另外要注意:两条自动触发路径(cron 直接调用 + 飞书 Workflow 状态变更触发)如果同时启用同一选题,会创建两套重复的流水线。
测试验证
两条测试链路全部通过:
心跳检测 — 发送 { target: "hermes", hook: "hermes/ping" } → 收到 { ok: true, pong: "2026-06-05T12:43:21Z" }。链路过:ntfy → WebSocket → Code → IF → Pong 响应。
流水线触发 — 发送 { target: "hermes", hook: "gzh-article/create", payload: { title: "测试", record_id: "rec_test" } } → SSH 执行成功,kanban 卡片创建完成。
扩展方向
这个架构天然支持多 target 扩展。目前只走了 hermes 分支,其他 target 可以按同样模式接入:
- qinglong — target 匹配后走 HTTP Request 调用青龙面板 API 运行任务
- homeassistant — 调用 HA API 控制设备开关
- notify — 转发到 ntfy 的其他主题或飞书
也可以加一个 config-reload hook,让消息通道同时承担配置热加载的职责。
总结
ntfy + n8n WebSocket 这个组合的优点是零外部依赖——ntfy 免费、n8n 已有,WebSocket 社区节点即装即用,不需要 Redis、RabbitMQ 之类的消息中间件。适合已有 n8n 部署、需要轻量消息触发的场景。
缺点是 ntfy.sh 是公共服务,消息经过外部网络,不适合内网敏感场景。如果对延迟或安全性有更高要求,可以自托管 ntfy 服务器(单二进制部署,资源消耗极低)。