Overview
参考文档:ntfy Documentation
一条通知从发布到手机的链路:
flowchart TD P["发布方(curl / 脚本 / CI / Claude Code)"] -->|"POST https://server/topic"| N["自建 ntfy 节点"] N -->|"常驻 WebSocket"| A["Android App"] N -->|"poll request 经 ntfy.sh 中继"| I["APNS"] I --> E["iPhone App"]ntfy是基于HTTP的发布/订阅通知服务,官方提供ntfy.sh与各平台客户端,节点也可以自建自建节点的好处是消息不经过第三方、路径完全可控,还可以只对内网/特定网段开放,适合配合自建 CI、监控等内部系统
ntfy 是什么
ntfy的模型极简:- 收发双方只约定一个 topic(主题名),发布方
POST到https://<server>/<topic>,请求正文就是通知内容 - 订阅方在 App 或网页里订阅同一个 topic 即可收到,无需注册账号
- 附加信息走
HTTP头:Title(标题)、Priority(优先级)、Tags(展示为 emoji)、Click(点击跳转的 URL)
- 收发双方只约定一个 topic(主题名),发布方
- topic 名即凭证:任何知道 topic 名的人都能发布与订阅,公网节点要配访问控制(见后文)
自建节点
服务端是一个单二进制(
ntfy serve),配置文件为/etc/ntfy/server.yml,核心字段:yamlbase-url: "https://ntfy.ishian.me" listen-http: ":80" cache-file: "/var/lib/ntfy/cache.db"Docker部署:bashdocker run -d --name ntfy \ -p 80:80 \ -v /var/lib/ntfy:/var/lib/ntfy \ -v /etc/ntfy/server.yml:/etc/ntfy/server.yml \ binwiederhier/ntfy serve
域名 + HTTPS 的情况
- 手机在蜂窝网络下访问,
TLS是基本要求,标准做法是域名 + 反向代理(Caddy/nginx)终结TLS,再转发到ntfy的listen-http base-url必须与 App 里填写的默认服务器地址完全一致,否则订阅看起来正常但推送时灵时不灵,这是最常见的一类配置错误iPhone即时推送的特殊处理:iOS不允许 App 常驻后台连接,即时推送必须走APNS,而APNS通道只有官方ntfy.sh接入- 自建节点在
server.yml中配置upstream-base-url: "https://ntfy.sh"后,消息到达时会向ntfy.sh转发一条轻量的 poll request,由它经APNS唤醒手机上的 App,App 再回到自己的节点拉取完整内容 - 也就是说消息正文只落自己的节点,
ntfy.sh只充当"敲门的门铃"
- 不配
upstream-base-url时 iOS 端退化为后台轮询,延迟以分钟计
裸 IP 的情况
- 没有域名时直接用
http://<IP>:<port>:Android:App 允许明文HTTP(需要在订阅设置里逐条允许),即时送达靠 App 与节点之间的常驻WebSocket连接,官方实测约17小时耗电0-1%,注意把 App 从电池优化中排除,否则连接会被系统杀掉iPhone:App 强制要求HTTPS,裸IP拿不到受信证书,基本不可用,想要 iOS 端就回到域名方案
WebSocket即时送达不依赖任何Google服务,F-Droid构建的 App 同样可用
访问控制
topic 名即凭证,公网节点建议关掉匿名访问:
yamlauth-file: "/var/lib/ntfy/user.db" auth-default-access: deny-allbashntfy user add ishian ntfy access ishian claude-code rw ntfy token add ishian之后发布与订阅都要带凭证:
curl加-H "Authorization: Bearer tk_xxx",App 在服务器设置里填同一个 token
发布与订阅
最小验证只需要一条
curl:bashcurl -H "Title: hello" -H "Tags: wave" \ -d "first push" \ https://ntfy.ishian.me/claude-codeAndroid:- App 中添加订阅,服务器填自己的节点地址,topic 填
claude-code - Play 版走
FCM,但自建节点默认没有配FCM凭证,实际用的是WebSocket即时连接,务必关闭对该 App 的电池优化 F-Droid版不含Google依赖,同样走WebSocket
- App 中添加订阅,服务器填自己的节点地址,topic 填
iPhone:- App 的默认服务器改成
https://ntfy.ishian.me(与base-url严格一致),配合节点侧的upstream-base-url即可获得APNS即时推送 - 节点没配
upstream时,只能打开 App 时手动拉取
- App 的默认服务器改成
排查顺序:先用
curl验证"手机能收到",再接入具体工具,两端分开定位
接入示例:Claude Code 的 Stop hook
Claude Code在settings.json中配置 hooks,按事件名触发外部命令,适合"跑长任务时不盯终端"的场景,用到两个事件:Stop:主回合结束(Claude完成响应)时触发,本例的主角Notification:权限确认等待、空闲提示等场景触发,stdin中带message字段,适合再挂一条"需要人工介入"的通知
- hook 命令从
stdin收到一段JSON,Stop事件的常用字段:last_assistant_message:Claude最后一条回复的正文,直接用它做通知内容,不必解析transcript_path——transcript 是异步写入的JSONL,Stop 时刻未必包含最后一条消息cwd、session_id、permission_mode等上下文字段,可拼进通知标题stop_hook_active:为true表示本次 Stop 由某个 Stop hook 的反馈引起,脚本里要检查并直接退出,否则可能形成循环
- 退出码语义(对
Stop尤其重要):exit 0:正常结束,纯通知类 hook 的唯一正确出口exit 2:阻止停止,把Claude拉回来继续干活,通知脚本绝不能走到这个分支- 其他非零:非阻塞错误,界面上提示 hook error
配置
用户级配置文件为
~/.claude/settings.json(设置了CLAUDE_CONFIG_DIR时在对应目录下),Stop事件不需要matcher:json{ "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "~/.config/claude/hooks/ntfy-stop.sh", "timeout": 10, "async": true } ] } ] } }~/.config/claude/hooks/ntfy-stop.sh:bash#!/usr/bin/env bash set -euo pipefail input=$(cat) if [[ $(jq -r '.stop_hook_active' <<<"$input") == "true" ]]; then exit 0 fi curl -fsS \ -H "Title: Claude Code $(jq -r '.cwd' <<<"$input")" \ -H "Tags: robot" \ -d "$(jq -r '.last_assistant_message // "task finished"' <<<"$input" | head -c 180)" \ https://ntfy.ishian.me/claude-codehead -c 180截断长回复,通知栏只放得下一小段;last_assistant_message可能缺失(如空回合),用// "task finished"兜底hooks 配置在会话启动时读取,改完需要开新会话,或用
/hooks查看生效状态同样的套路可以接到任何能发
HTTP请求的地方:CI流水线结束、cron任务失败、监控告警脚本,往 topic 里POST一条即可