通知推送拆成两个渠道:QQ 机器人和 MeoW,各自带开关和测试连通

用户要求两件事:① QQ bot 推送要能测连通、并加一个开关;② 照 MeoW 的接口
文档(https://www.chuckfang.com/MeoW/api_doc.html)加一个推送渠道,同样要有
测试和开关。确认过的三个决定:QQ bot 就是现有 webhook;事件勾选两个渠道
共用、开关按渠道分开;MeoW 只填一个昵称。

1) 通知推送改成「共享事件勾选 + 每渠道一个开关」
   - notify.go: notifySetting 扩成两个渠道,新增 qqReady()/meowReady();
     sendNotify 按渠道各自起后台 goroutine 分发,事件没勾选、或两个渠道都
     没启用时什么都不做
   - config.go: 新增 notifyEnabled(QQ 机器人渠道开关)、meowEnabled、
     meowNickname、meowMsgType、meowHtmlHeight、meowImgUrl
   - 关掉开关只是暂停推送,地址、昵称这些配置都留着,随时能再打开

2) 老配置的兼容(重要)
   - notifyEnabled 是普通 bool,老 config.json 里没有这个字段会解成 false,
     升级后会把已经配好的推送静默关掉
   - 解法:loadConfig() 里先置 true 再 json.Unmarshal(Unmarshal 只覆盖文件里
     出现的字段),默认值里也写 true;文件里显式写 false 的仍然会关掉
   - 已加回归测试 TestNotifyEnabledBackwardCompat 覆盖三种写法

3) MeoW 渠道(新增 meow.go)
   - 按文档走 POST {昵称},title/msg/url/imgUrl 放 json body,
     msgType/htmlHeight 是 query 参数(文档明确说这两个不属于 JSON 参数)
   - 文档自己前后矛盾:响应格式那节写 msg,示例写 message,两个字段都解析
   - 空字段一律不放进 body:body 里的 title 优先级高于路径参数,
     传空串会把 MeoW 的默认标题顶掉
   - 业务状态码翻成中文(400 参数错、403 内容被拦、404 昵称未注册、
     429 频控、500 服务器错误),失败信息直接显示在页面上
   - msgType 为 html 时渲染成 HTML(首行加粗、链接可点、内容转义),
     高度默认 200、范围 40-2000;事件正文复用 webhook 的那份 text

4) MeoW 的错误昵称会封 IP,所以加了保护(踩出来的)
   - 实测用不存在的昵称打真实接口,返回的提示是「一天之内累计3次输错对方
     昵称您的IP将被封禁」。而开播+开始录制+下播+合并完成一天轻松触发 4 次,
     昵称配错就会把用户 IP 封了
   - meow.go: 新增 meowBadNick 记录,pushMeow 收到 404 就记下这个昵称;
     sendNotify 和测试接口命中记录时直接跳过/拒绝并说明原因,
     改掉昵称或重启程序即恢复
   - 只有 404 触发拦截:429 是频控、400 是参数问题,都应该继续尝试
   - 已加测试 TestMeowBadNickGuard(404 要拦、429/400 不能拦、不影响别的昵称)

5) 测试连通
   - /api/notify/test 合并成一个接口,用 channel 区分 qq / meow,
     每个渠道的配置下面各有一个「测试连通」按钮
   - 用页面上当前填的值直接测、不用先保存,留空的项自动用已保存的值补齐;
     测试不受渠道开关和事件勾选影响(本来就是为了打开之前先确认配置)
   - 成功返回 {ok, detail},detail 就是页面上的结论:QQ 渠道显示 webhook 的
     HTTP 状态码和耗时,MeoW 渠道显示它返回的 status 和耗时;
     webhook 返回 200 只代表地址口令没问题,能不能收到要看机器人那边,
     这点也写在结果里
   - /api/status 新增 meowBlocked,页面上红字显示「当前推不出去:…」
     (昵称没填、格式不对、或已被判过未注册)

6) 页面
   - 通知区分成「事件勾选 / 渠道一 QQ 机器人 / 渠道二 MeoW」三块,
     每块用一条带标题的细线隔开,两个渠道各带开关和测试按钮
   - 消息类型选 text 时自动收起 HTML 高度那一行

另外:README 的通知推送一节重写(两个渠道的对比表、MeoW 的频控与封禁提醒、
测试连通的说明),配置文件详解补上新字段;doc/webapi.md 补 /api/notify/test
和 meowBlocked。doc/momoko-acfunlive-notify.py 本来就处理 event="test",不用改。

已验证:新增 notify_test.go,15 个用例全部通过(URL 拼接与转义、昵称校验、
消息类型与高度归一、HTML 渲染与转义、接口成功/失败/空字段处理、6 种业务状态码
的文案、404 拦截保护、老配置兼容、测试接口各分支),其中 TestSendNotifyFanOut
是端到端分发测试:两个渠道都开→各收到 1 条、关掉任一个→只剩另一个、
两个都关→0 条、事件没勾→0 条。真机冒烟两轮:页面控件齐全、老配置默认开启、
QQ 渠道实测本地 webhook 收到完整推送(含 Authorization 和 X-Auth-Token 头与群号)、
空值回退到已保存配置、6 种非法输入全 400、开关能落盘且关开关不清地址、
MeoW 真实接口返回的 404 被正确翻成中文。全量 34 个测试通过,
Windows/Linux 双平台构建、go vet、gofmt 全干净。
This commit is contained in:
tamakyi
2026-09-25 13:42:49 +08:00
parent 36fe7a978c
commit 8528499a69
7 changed files with 1279 additions and 61 deletions
+41 -2
View File
@@ -86,6 +86,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1
"authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成
"notifyEnabled": true, // QQ 机器人(webhook)推送的渠道开关,关掉后即使填了地址也不推送
"notifyUrl": "", // 开播/下播等事件推送到该地址(HTTP POST JSON),留空不推送
"notifyToken": "", // 推送时附带的 Bearer 口令,留空不带
"notifyGroup": "", // 推送时带上的群号,机器人可以直接用它决定发到哪个群
@@ -94,6 +95,12 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe
"notifyRecordStart": false, // 开始录制直播视频时推送
"notifyMergeDone": false, // 自动合并完成时推送
"meowEnabled": false, // MeoW 推送的渠道开关
"meowNickname": "", // MeoW 昵称,推送地址里的路径参数
"meowMsgType": "text", // MeoW 消息类型:text(纯文本)或 html
"meowHtmlHeight": 200, // MeoW 在 msgType 为 html 时的显示高度(像素)
"meowImgUrl": "", // MeoW 通知图标 URL,建议 216×216 的 PNG,留空用默认图标
"ffmpegPath": "", // ffmpeg 可执行文件(或它所在文件夹)的路径,留空则自动查找
"recordFormat": "mp4", // 录播文件的封装格式:mp4、flv、ts、mkv
"recordSubdir": "record", // 录播原片和弹幕文件存放在保存目录下的子文件夹名
@@ -201,7 +208,18 @@ web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动
### 通知推送
填好`notifyUrl`并勾选需要的事件后,程序会把事件以 JSON POST 到该地址,可以用来接自己的机器人(NoneBot、Koishi 等)或任何 HTTP 服务。
开播、下播、开始录制、合并完成这四个事件可以推到两个渠道,两个渠道各推各的、互不影响:
| 渠道 | 接收方 | 需要的配置 |
| --- | --- | --- |
| QQ 机器人 | 自己的机器人(NoneBot、Koishi 等)或任何 HTTP 服务 | `notifyUrl`(+ 可选的`notifyToken`、`notifyGroup`) |
| MeoW | 手机上 MeoW App 的通知 | `meowNickname` |
「推哪些事件」只有一套勾选(`notifyLiveOn`等四个),两个渠道共用;每个渠道另有自己的开关(`notifyEnabled`、`meowEnabled`)。关掉开关只是暂停推送,地址、昵称这些配置都会留着,随时可以再打开。**没勾选的事件、或两个渠道都没启用时,程序什么都不做。**
#### 渠道一:QQ 机器人
填好`notifyUrl`并勾选需要的事件后,程序会把事件以 JSON POST 到该地址。
推送的请求体:
@@ -219,12 +237,33 @@ web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动
}
```
`notifyToken`非空时会以`Authorization: Bearer <口令>`和`X-Auth-Token`两个头一起发出去。推送在后台进行且有 10 秒超时,失败只在日志里记警告,不会影响录制。同一场直播的开播通知只会推送一次。web UI 设置区的`发送测试通知`按钮可以用当前填写的地址直接测试(不需要先保存)。
`notifyToken`非空时会以`Authorization: Bearer <口令>`和`X-Auth-Token`两个头一起发出去。推送在后台进行且有 10 秒超时,失败只在日志里记警告,不会影响录制。同一场直播的开播通知只会推送一次。
`notifyGroup`是随推送下发的群号,留空则不带这个字段。它只是原样透传给机器人,程序自己不做任何判断——好处是群号、口令这些都能在 web UI 里改,不用去动机器人那边的配置。
接 NoneBot 的例子见`doc/momoko-acfunlive-notify.py`:把这个文件复制到 NoneBot 项目的插件目录即可,收到后直接把`text`发到群里。插件会优先使用推送里的`group`,只有推送没带时才回退到环境变量`ACFUNLIVE_NOTIFY_GROUP`,所以群号在 acfunlive 页面上改完立即生效,不用重启机器人。
#### 渠道二:MeoW
[MeoW](https://www.chuckfang.com/MeoW/api_doc.html) 是个消息推送 App,手机上装好、注册自己的昵称后,把昵称填进`meowNickname`打开开关,就能直接收到通知,不用自己架机器人。
- 消息内容用事件正文(就是上面 webhook 里的`text`),标题按事件取「开播提醒 / 下播提醒 / 开始录制 / 合并完成」
- 开播、开始录制这类事件会带上直播间地址,MeoW 里点通知就能跳回直播间;合并完成带的是本地成片路径,手机上打不开,所以不带跳转链接
- `meowMsgType`为`html`时会渲染成 HTML(第一行加粗、链接可点),`meowHtmlHeight`控制显示高度(默认 200,范围 40–2000);为`text`时就是纯文本,也是默认值
- `meowImgUrl`可以换掉通知图标,官方建议用 216×216 的 PNG
**注意 MeoW 的频控和封禁规则**:非会员是 3 秒 1 条、每分钟 15 条、每小时 60 条,超了返回 429;更麻烦的是**一天内累计 3 次推给不存在的昵称,它会把你的 IP 封掉**。所以程序做了保护:一旦某个昵称被 MeoW 判定为「昵称未注册」,就不再向它推送,页面上会红字提示原因,改掉昵称(或重启程序)才恢复。请先把昵称填对再打开开关。
#### 测试连通
两个渠道各有一个`测试连通`按钮,就在各自的配置下面:
- 用**页面上当前填的值**直接测,不用先保存;留空的项自动用已保存的值补齐
- 测试不受渠道开关和事件勾选影响,关着开关也能测(本来就是为了在打开之前先确认配置对不对)
- 成功后会显示实际结果:QQ 渠道显示 webhook 返回的 HTTP 状态码和耗时,MeoW 渠道显示它返回的`status`和耗时;失败则显示具体原因,比如`MeoW 返回 status 404(昵称未注册)`
webhook 返回 200 只代表**地址和口令没问题**,能不能真的收到还要看机器人那边有没有正常处理——这一点测试结果里也会写明。
### 文件管理
web UI 的`文件管理`区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。
+26 -4
View File
@@ -89,6 +89,7 @@ type configData struct {
AuthToken string `json:"authToken"` // web UI 管理接口的访问口令,首次启动自动生成
NotifyEnabled bool `json:"notifyEnabled"` // QQ 机器人(webhook)推送的渠道开关,关掉后即使填了地址也不推送
NotifyURL string `json:"notifyUrl"` // 事件推送到该地址(HTTP POST JSON),留空不推送
NotifyToken string `json:"notifyToken"` // 推送时附带的 Bearer 口令,留空不带
NotifyGroup string `json:"notifyGroup"` // 推送时带上的群号,机器人可以直接用它决定发到哪个群
@@ -97,6 +98,12 @@ type configData struct {
NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送
NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送
MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关
MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数
MeowMsgType string `json:"meowMsgType"` // MeoW 消息类型:text(默认,纯文本)或 html
MeowHTMLHeight int `json:"meowHtmlHeight"` // MeoW 在 msgType 为 html 时的显示高度(像素),默认 200
MeowImgURL string `json:"meowImgUrl"` // MeoW 通知图标 URL,建议 216x216 的 PNG,留空用 MeoW 默认图标
FFmpegPath string `json:"ffmpegPath"` // ffmpeg 可执行文件(或所在文件夹)的路径,留空则自动查找
RecordFormat string `json:"recordFormat"` // 录播视频的封装格式:mp4(默认,与原版一致)、flv、ts、mkv
@@ -191,10 +198,11 @@ func recordFileExt() string {
// 默认设置
var config = configData{
UIDs: uidList{},
WebPort: 51880,
RecordSubdir: defaultRecordSubdir,
MergeSubdir: defaultMergeSubdir,
UIDs: uidList{},
WebPort: 51880,
RecordSubdir: defaultRecordSubdir,
MergeSubdir: defaultMergeSubdir,
NotifyEnabled: true,
}
// Windows 下不能用作文件(夹)名的保留字
@@ -256,6 +264,10 @@ func loadConfig() {
checkErr(err)
if json.Valid(data) {
// 老设置文件里没有 notifyEnabled 这个字段,默认按「开启」处理,
// 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面,
// 文件里显式写了 false 时仍会被覆盖成 false
config.NotifyEnabled = true
err = json.Unmarshal(data, &config)
checkErr(err)
} else {
@@ -372,6 +384,16 @@ func checkConfig() {
}
// 录播格式写错不致命,回退到 mp4,免得整个程序起不来
config.RecordFormat = normalizeRecordFormat(config.RecordFormat)
// MeoW 这几项写错同样不致命:类型和高度回退到默认值,昵称不可用只警告
config.MeowNickname = strings.TrimSpace(config.MeowNickname)
config.MeowMsgType = normalizeMeowMsgType(config.MeowMsgType)
config.MeowImgURL = strings.TrimSpace(config.MeowImgURL)
if config.MeowHTMLHeight <= 0 {
config.MeowHTMLHeight = defaultMeowHTMLHeight
}
if msg := checkMeowNickname(config.MeowNickname, true); msg != "" {
lPrintWarnf("%s里的meowNickname不可用,不会推送 MeoW:%s", configFile, msg)
}
// ffmpeg 路径填错时不致命(可能只是还没打算录视频),清空后回退到自动查找
if msg := checkFFmpegPath(config.FFmpegPath); msg != "" {
lPrintWarnf("%s里的ffmpegPath不可用,将改为自动查找 ffmpeg:%s", configFile, msg)
+13
View File
@@ -93,3 +93,16 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全
`POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。
合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。
`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。
```
// QQ 机器人(webhook)渠道,channel 不填就按这个
{ "channel": "qq", "url": "http://127.0.0.1:8080/acfunlive/notify", "token": "可选", "group": "可选" }
// MeoW 渠道
{ "channel": "meow", "nickname": "你的昵称", "msgType": "text", "imgUrl": "可选", "htmlHeight": 200 }
```
成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。
MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。
+295
View File
@@ -0,0 +1,295 @@
// MeoW 消息推送,接口文档:https://www.chuckfang.com/MeoW/api_doc.html
// MeoW 的 HTTP 状态一律是 200,真正的结果在响应 json 的 status 字段里,所以要读 body 才能判断成败
package main
import (
"bytes"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"sync"
"time"
)
const (
meowMsgTypeText = "text"
meowMsgTypeHTML = "html"
// HTML 消息的显示高度,文档默认 200,limits 是防止填出个渲染不出来的值
defaultMeowHTMLHeight = 200
minMeowHTMLHeight = 40
maxMeowHTMLHeight = 2000
)
// 接口地址。文档也提供 http,但推送内容会带直播标题、昵称这些信息,统一走 https。
// 变量而不是常量,测试里可以换成自己的服务器
var meowAPIBase = "https://api.chuckfang.com/"
// MeoW 对「昵称不存在」很敏感:官方提示一天内累计 3 次输错昵称就会封 IP。
// 所以一旦确认某个昵称不可用就不再向它推送,等昵称被改掉(或重启程序)才恢复,
// 免得开播/下播/合并完成这些事件反复触发把用户 IP 封了
var meowBadNick struct {
sync.Mutex
nick string // 已知不可用的昵称,空表示没有
reason string
}
// 记下一个不可用的昵称
func markMeowBadNick(nick, reason string) {
nick = strings.TrimSpace(nick)
if nick == "" {
return
}
meowBadNick.Lock()
defer meowBadNick.Unlock()
if meowBadNick.nick == nick {
return
}
meowBadNick.nick, meowBadNick.reason = nick, reason
}
// 清掉「昵称不可用」的记录。生产里靠改昵称或重启程序恢复,这里主要给测试用
func resetMeowBadNick() {
meowBadNick.Lock()
defer meowBadNick.Unlock()
meowBadNick.nick, meowBadNick.reason = "", ""
}
// 昵称是否已被判定不可用,返回原因(空字符串表示可以用)
func meowBadNickReason(nick string) string {
nick = strings.TrimSpace(nick)
if nick == "" {
return ""
}
meowBadNick.Lock()
defer meowBadNick.Unlock()
if meowBadNick.nick == nick {
return meowBadNick.reason
}
return ""
}
// MeoW 昵称为什么推不出去(没填、格式不对,或已被 MeoW 判过不存在),空字符串表示没问题
func meowNickProblem(nick string) string {
if strings.TrimSpace(nick) == "" {
return "还没有填写昵称"
}
if msg := checkMeowNickname(nick, false); msg != "" {
return msg
}
return meowBadNickReason(nick)
}
// MeoW 的响应体。文档「响应格式」那节写的是 msg,示例里写的是 message,两个都收
type meowResponse struct {
Status int `json:"status"`
Msg string `json:"msg"`
Message string `json:"message"`
}
func (r meowResponse) detail() string {
if s := strings.TrimSpace(r.Message); s != "" {
return s
}
return strings.TrimSpace(r.Msg)
}
// 推送用的客户端。MeoW 是公网服务,超时给得比 webhook 宽松一点
var meowClient = &http.Client{Timeout: 15 * time.Second}
// MeoW 昵称是否可用。allowEmpty 为 true 时允许留空(表示还没配,不影响程序启动)
func checkMeowNickname(nick string, allowEmpty bool) string {
nick = strings.TrimSpace(nick)
if nick == "" {
if allowEmpty {
return ""
}
return "还没填写 MeoW 昵称"
}
// 昵称是推送地址里的路径参数,文档明确不允许斜杠,问号井号这类也会把地址截断
if strings.ContainsAny(nick, `/\?#`) {
return "MeoW 昵称不能包含 / \\ ? # 这些字符:" + nick
}
if len([]rune(nick)) > 64 {
return "MeoW 昵称太长(超过 64 个字符):" + nick
}
return ""
}
// MeoW 的消息类型,只认 text 和 html,其它和空值都回退到 text
func normalizeMeowMsgType(t string) string {
switch strings.ToLower(strings.TrimSpace(t)) {
case meowMsgTypeHTML:
return meowMsgTypeHTML
case "", meowMsgTypeText:
return meowMsgTypeText
}
lPrintWarnf("不支持的 MeoW 消息类型 %q,改用 %s(可选:text、html)", t, meowMsgTypeText)
return meowMsgTypeText
}
// HTML 消息的显示高度,超出范围就夹到边界,非正数用默认值
func clampMeowHTMLHeight(h int) int {
if h <= 0 {
return defaultMeowHTMLHeight
}
if h < minMeowHTMLHeight {
return minMeowHTMLHeight
}
if h > maxMeowHTMLHeight {
return maxMeowHTMLHeight
}
return h
}
// 拼推送地址。msgType、htmlHeight 是请求参数,不能放进 json body 里
func meowURL(nick, msgType string, htmlHeight int) string {
q := url.Values{}
q.Set("msgType", msgType)
if msgType == meowMsgTypeHTML {
q.Set("htmlHeight", strconv.Itoa(clampMeowHTMLHeight(htmlHeight)))
}
return meowAPIBase + url.PathEscape(strings.TrimSpace(nick)) + "?" + q.Encode()
}
// MeoW 的业务状态码说明
func meowStatusText(status int) string {
switch status {
case 200:
return "推送成功"
case 400:
return "标题或消息参数错误"
case 403:
return "内容被 MeoW 的策略拦下,不允许发送"
case 404:
return "昵称未注册"
case 429:
return "触发频率限制(非会员为 3 秒 1 条、每分钟 15 条、每小时 60 条)"
case 500:
return "MeoW 服务器错误"
}
return ""
}
// 截断过长的响应内容,避免把整个 body 打进日志或页面
func briefBody(raw []byte) string {
s := strings.TrimSpace(string(raw))
if len(s) > 300 {
s = s[:300] + "…"
}
return s
}
// 推送一条消息到 MeoW。昵称是接收者,title 留空时 MeoW 会用它自己的默认标题
func pushMeow(nick, title, msg, jumpURL, imgURL, msgType string, htmlHeight int) error {
if err := checkMeowNickname(nick, false); err != "" {
return fmt.Errorf("%s", err)
}
if strings.TrimSpace(msg) == "" {
return fmt.Errorf("推送内容为空")
}
msgType = normalizeMeowMsgType(msgType)
// 空字段直接不放进去:body 里的 title 优先级高于路径参数,
// 传空字符串有可能把 MeoW 的默认标题顶掉
body := map[string]string{"msg": msg}
if s := strings.TrimSpace(title); s != "" {
body["title"] = s
}
if s := strings.TrimSpace(jumpURL); s != "" {
body["url"] = s
}
if s := strings.TrimSpace(imgURL); s != "" {
body["imgUrl"] = s
}
data, err := json.Marshal(body)
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPost, meowURL(nick, msgType, htmlHeight), bytes.NewReader(data))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := meowClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
var r meowResponse
if err := json.Unmarshal(raw, &r); err != nil {
return fmt.Errorf("MeoW 返回的不是合法 json(HTTP %d):%s", resp.StatusCode, briefBody(raw))
}
if r.Status != 200 {
// 昵称不存在是最危险的一种失败:MeoW 会对错误昵称记数并封 IP,
// 所以记下来,后续不再向这个昵称重试
if r.Status == 404 {
markMeowBadNick(nick, meowStatusText(404))
}
if text := meowStatusText(r.Status); text != "" {
return fmt.Errorf("MeoW 返回 status %d(%s)%s", r.Status, text, joinDetail(r.detail()))
}
return fmt.Errorf("MeoW 返回 status %d%s", r.Status, joinDetail(r.detail()))
}
return nil
}
func joinDetail(detail string) string {
if detail == "" {
return ""
}
return ":" + detail
}
// 各事件在通知里的短标题,MeoW 的通知栏和 webhook 的正文都用得到
func notifyEventTitle(event string) string {
switch event {
case notifyEventLiveOn:
return "开播提醒"
case notifyEventLiveOff:
return "下播提醒"
case notifyEventRecordStart:
return "开始录制"
case notifyEventMergeDone:
return "合并完成"
}
return "AcFun 直播录制"
}
// 看起来像 http/https 链接
func isHTTPURL(s string) bool {
s = strings.ToLower(strings.TrimSpace(s))
return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://")
}
// 把事件正文渲染成 MeoW 的 HTML 消息:第一行加粗,链接单独成行时做成可点的,其余每行一个段落
func meowHTMLText(text string) string {
var b strings.Builder
first := true
for _, line := range strings.Split(strings.TrimSpace(text), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
esc := html.EscapeString(line)
switch {
case first:
b.WriteString("<p><b>" + esc + "</b></p>")
first = false
case isHTTPURL(line):
b.WriteString(`<p><a href="` + esc + `">` + esc + `</a></p>`)
default:
b.WriteString("<p>" + esc + "</p>")
}
}
return b.String()
}
+89 -20
View File
@@ -65,9 +65,18 @@ func notifiedLiveOnce(liveID string) bool {
// 推送设置的一份快照,避免在推送过程中读到正在被修改的配置
type notifySetting struct {
url string
token string
group string
// QQ 机器人(webhook)渠道
qqEnabled bool
url string
token string
group string
// MeoW 渠道
meowEnabled bool
meowNick string
meowMsgType string
meowHTML int
meowImg string
// 推哪些事件,两个渠道共用
liveOn bool
liveOff bool
recordStart bool
@@ -78,9 +87,15 @@ func currentNotifySetting() notifySetting {
streamers.RLock()
defer streamers.RUnlock()
return notifySetting{
qqEnabled: config.NotifyEnabled,
url: strings.TrimSpace(config.NotifyURL),
token: strings.TrimSpace(config.NotifyToken),
group: strings.TrimSpace(config.NotifyGroup),
meowEnabled: config.MeowEnabled,
meowNick: strings.TrimSpace(config.MeowNickname),
meowMsgType: normalizeMeowMsgType(config.MeowMsgType),
meowHTML: clampMeowHTMLHeight(config.MeowHTMLHeight),
meowImg: strings.TrimSpace(config.MeowImgURL),
liveOn: config.NotifyLiveOn,
liveOff: config.NotifyLiveOff,
recordStart: config.NotifyRecordStart,
@@ -103,11 +118,41 @@ func (n notifySetting) enabled(event string) bool {
return false
}
// 推送一个事件。没配置推送地址或没开启该事件时什么都不做,
// QQ 机器人渠道是否具备推送条件:渠道开关打开且填了地址
func (n notifySetting) qqReady() bool {
return n.qqEnabled && n.url != ""
}
// MeoW 渠道是否具备推送条件:渠道开关打开、昵称可用、且这个昵称没被 MeoW 判过不存在
func (n notifySetting) meowBlockedReason() string {
return meowNickProblem(n.meowNick)
}
func (n notifySetting) meowReady() bool {
return n.meowEnabled && n.meowBlockedReason() == ""
}
// 页面上要显示的 MeoW 提示,没问题时返回空字符串。调用方需要持有 streamers 锁
func meowStatusProblem() string {
if !config.MeowEnabled {
return ""
}
return meowNickProblem(config.MeowNickname)
}
// 推送一个事件。没开启对应事件、或两个渠道都没启用时什么都不做。
// 推送在后台 goroutine 里进行,失败只打警告,不会影响录制
func sendNotify(event string, uid int, name, title, url, file, text string) {
n := currentNotifySetting()
if n.url == "" || !n.enabled(event) {
if !n.enabled(event) {
return
}
qq, meow := n.qqReady(), n.meowReady()
if !qq && !meow {
// MeoW 开着却推不了时说清楚原因,不然用户会以为推送整个坏了
if n.meowEnabled {
lPrintWarnf("MeoW 渠道已启用但推不出去(%s),本次%s通知没有推送", n.meowBlockedReason(), event)
}
return
}
p := notifyPayload{
@@ -121,26 +166,50 @@ func sendNotify(event string, uid int, name, title, url, file, text string) {
Text: text,
Time: getLogTime(),
}
// 推送地址可能在推送过程中被改掉,这里把地址和口令带进闭包,保证用的是同一次配置
addr, token := n.url, n.token
safeGo("推送"+event+"通知", func() {
if err := postNotify(addr, token, p); err != nil {
lPrintWarnf("推送%s通知失败:%v", event, err)
return
// 推送地址和口令可能在推送过程中被改掉,这里带进闭包,保证用的是同一次配置
if qq {
addr, token := n.url, n.token
safeGo("推送"+event+"通知到QQ机器人", func() {
if _, err := postNotify(addr, token, p); err != nil {
lPrintWarnf("推送%s通知到 QQ 机器人失败:%v", event, err)
return
}
lPrintf("已推送%s通知到 QQ 机器人:%s", event, p.Text)
})
}
if meow {
nick, img, msgType, msgHeight := n.meowNick, n.meowImg, n.meowMsgType, n.meowHTML
// 合并完成时带的是本地成片路径,手机上打不开,所以只认 http/https 的直播间地址
jump := p.URL
if !isHTTPURL(jump) {
jump = ""
}
lPrintf("已推送%s通知:%s", event, p.Text)
})
eventTitle := notifyEventTitle(event)
content := p.Text
if msgType == meowMsgTypeHTML {
content = meowHTMLText(p.Text)
}
safeGo("推送"+event+"通知到MeoW", func() {
if err := pushMeow(nick, eventTitle, content, jump, img, msgType, msgHeight); err != nil {
lPrintWarnf("推送%s通知到 MeoW 失败:%v", event, err)
return
}
lPrintf("已推送%s通知到 MeoW(%s):%s", event, nick, p.Text)
})
}
}
// 把事件 POST 到 webhook
func postNotify(addr, token string, p notifyPayload) error {
// 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节)
func postNotify(addr, token string, p notifyPayload) (int, error) {
data, err := json.Marshal(p)
if err != nil {
return err
return 0, err
}
req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data))
if err != nil {
return err
return 0, err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
if token != "" {
@@ -150,14 +219,14 @@ func postNotify(addr, token string, p notifyPayload) error {
resp, err := notifyClient.Do(req)
if err != nil {
return err
return 0, err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
return resp.StatusCode, fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
return nil
return resp.StatusCode, nil
}
// 检查设置里的推送地址,返回错误信息,空字符串表示通过
+594
View File
@@ -0,0 +1,594 @@
package main
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
)
// 把 MeoW 的接口地址临时换成本地测试服务器
func withMeowServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
t.Helper()
srv := httptest.NewServer(h)
old := meowAPIBase
meowAPIBase = srv.URL + "/"
t.Cleanup(func() {
meowAPIBase = old
srv.Close()
})
return srv
}
func TestCheckMeowNickname(t *testing.T) {
cases := []struct {
nick string
allow bool
wantOK bool
}{
{"JohnDoe", false, true},
{"小小怪", false, true},
{" JohnDoe ", false, true}, // 前后空白会被去掉
{"", true, true}, // 允许留空
{"", false, false}, // 要推送时不能为空
{"John/Doe", false, false},
{`John\Doe`, false, false},
{"John?Doe", false, false},
{"John#Doe", false, false},
{strings.Repeat("a", 65), false, false},
{strings.Repeat("a", 64), false, true},
}
for _, c := range cases {
got := checkMeowNickname(c.nick, c.allow)
if (got == "") != c.wantOK {
t.Errorf("checkMeowNickname(%q, %v) = %q,期望通过=%v", c.nick, c.allow, got, c.wantOK)
}
}
}
func TestNormalizeMeowMsgTypeAndHeight(t *testing.T) {
for _, c := range []struct{ in, want string }{
{"", meowMsgTypeText},
{"text", meowMsgTypeText},
{"TEXT", meowMsgTypeText},
{" html ", meowMsgTypeHTML},
{"HTML", meowMsgTypeHTML},
{"markdown", meowMsgTypeText}, // 认不出来就回退
} {
if got := normalizeMeowMsgType(c.in); got != c.want {
t.Errorf("normalizeMeowMsgType(%q) = %q,期望 %q", c.in, got, c.want)
}
}
for _, c := range []struct{ in, want int }{
{0, defaultMeowHTMLHeight},
{-5, defaultMeowHTMLHeight},
{10, minMeowHTMLHeight},
{200, 200},
{99999, maxMeowHTMLHeight},
} {
if got := clampMeowHTMLHeight(c.in); got != c.want {
t.Errorf("clampMeowHTMLHeight(%d) = %d,期望 %d", c.in, got, c.want)
}
}
}
func TestMeowURL(t *testing.T) {
got := meowURL("小小怪", meowMsgTypeText, 200)
// text 类型不该带上 htmlHeight
if strings.Contains(got, "htmlHeight") {
t.Errorf("text 类型不该带 htmlHeight:%s", got)
}
if !strings.Contains(got, "msgType=text") {
t.Errorf("缺少 msgType:%s", got)
}
// 中文昵称必须转义,不能原样进地址
if strings.Contains(got, "小小怪") {
t.Errorf("昵称没做 URL 转义:%s", got)
}
if !strings.HasSuffix(got, "/%E5%B0%8F%E5%B0%8F%E6%80%AA?msgType=text") {
t.Errorf("昵称转义结果不对:%s", got)
}
got = meowURL("JohnDoe", meowMsgTypeHTML, 350)
if !strings.Contains(got, "msgType=html") || !strings.Contains(got, "htmlHeight=350") {
t.Errorf("html 类型的参数不对:%s", got)
}
// 超出范围的高度要夹住
if got := meowURL("JohnDoe", meowMsgTypeHTML, 99999); !strings.Contains(got, "htmlHeight=2000") {
t.Errorf("高度没夹住:%s", got)
}
}
func TestMeowHTMLText(t *testing.T) {
got := meowHTMLText("Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1\n<i>不该被解析</i>")
for _, want := range []string{
"<p><b>Xsphere 开播了</b></p>", // 第一行加粗
"<p>今天画点啥</p>", // 普通行
`<p><a href="https://live.acfun.cn/1">https://live.acfun.cn/1</a></p>`, // 链接可点
"&lt;i&gt;不该被解析&lt;/i&gt;", // 内容要转义
} {
if !strings.Contains(got, want) {
t.Errorf("HTML 里缺少 %q,实际:%s", want, got)
}
}
if strings.Contains(got, "<i>") {
t.Errorf("内容没被转义,存在注入风险:%s", got)
}
if got := meowHTMLText(" "); got != "" {
t.Errorf("空内容应该得到空串,实际 %q", got)
}
}
// 成功路径:确认请求本身拼对了,并且能正确解析文档里的两种响应字段(message / msg)
func TestPushMeowSuccess(t *testing.T) {
var gotPath, gotQuery, gotMethod, gotType string
var gotBody map[string]string
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
gotPath, gotQuery, gotMethod = r.URL.Path, r.URL.RawQuery, r.Method
gotType = r.Header.Get("Content-Type")
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
err := pushMeow("小小怪", "开播提醒", "Xsphere 开播了", "https://live.acfun.cn/1", "https://x/icon.png",
meowMsgTypeHTML, 350)
if err != nil {
t.Fatalf("推送失败:%v", err)
}
if gotMethod != http.MethodPost {
t.Errorf("方法应该是 POST,实际 %s", gotMethod)
}
if gotPath != "/小小怪" {
t.Errorf("路径不对:%q", gotPath)
}
if !strings.Contains(gotQuery, "msgType=html") || !strings.Contains(gotQuery, "htmlHeight=350") {
t.Errorf("查询参数不对:%q", gotQuery)
}
if !strings.HasPrefix(gotType, "application/json") {
t.Errorf("Content-Type 不对:%q", gotType)
}
for k, want := range map[string]string{
"msg": "Xsphere 开播了",
"title": "开播提醒",
"url": "https://live.acfun.cn/1",
"imgUrl": "https://x/icon.png",
} {
if gotBody[k] != want {
t.Errorf("body 里的 %s = %q,期望 %q", k, gotBody[k], want)
}
}
}
// 空字段不能进 body:body 里的 title 优先级比路径参数高,传空串会把默认标题顶掉
func TestPushMeowOmitsEmptyFields(t *testing.T) {
var gotBody map[string]any
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
_ = json.Unmarshal(raw, &gotBody)
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
if err := pushMeow("JohnDoe", "", "只有正文", "", "", meowMsgTypeText, 0); err != nil {
t.Fatalf("推送失败:%v", err)
}
if len(gotBody) != 1 || gotBody["msg"] != "只有正文" {
t.Errorf("应该只发 msg 一个字段,实际 %v", gotBody)
}
}
// 失败路径:文档里 HTTP 状态一律是 200,成败只能看 body 里的 status
func TestPushMeowStatusCodes(t *testing.T) {
cases := []struct {
name string
body string
want string
}{
{"昵称未注册", `{"status":404,"message":"昵称未注册"}`, "昵称未注册"},
{"频率超限", `{"status":429,"msg":"too many requests"}`, "频率限制"},
{"内容被拦", `{"status":403,"message":"forbidden"}`, "策略"},
{"参数错误", `{"status":400,"message":"bad request"}`, "参数错误"},
{"服务器错误", `{"status":500,"msg":"服务器错误"}`, "MeoW 服务器错误"},
{"不是 json", `<!doctype html><html>502</html>`, "不是合法 json"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(c.body))
})
err := pushMeow("JohnDoe", "标题", "正文", "", "", meowMsgTypeText, 0)
if err == nil {
t.Fatalf("status 不是 200 时必须报错")
}
if !strings.Contains(err.Error(), c.want) {
t.Errorf("错误信息里应包含 %q,实际:%v", c.want, err)
}
})
}
}
func TestPushMeowRejectsBadInput(t *testing.T) {
// 昵称不合法时必须在发请求前就拦下来
if err := pushMeow("bad/nick", "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
t.Error("昵称带斜杠应该报错")
}
if err := pushMeow("JohnDoe", "标题", " ", "", "", meowMsgTypeText, 0); err == nil {
t.Error("内容为空应该报错")
}
}
// MeoW 对错误昵称记数并封 IP,所以 404 之后必须停止重试,否则事件反复触发会把用户 IP 封掉
func TestMeowBadNickGuard(t *testing.T) {
const nick = "guard_test_nick"
resetMeowBadNick()
t.Cleanup(resetMeowBadNick)
if got := meowBadNickReason(nick); got != "" {
t.Fatalf("一开始不该有记录,实际 %q", got)
}
if got := meowNickProblem(nick); got != "" {
t.Fatalf("没被封之前应该没问题,实际 %q", got)
}
// MeoW 返回 404 后,这个昵称要被记住
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(`{"status":404,"message":"昵称未注册"}`))
})
if err := pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
t.Fatal("404 时应该报错")
}
reason := meowBadNickReason(nick)
if reason == "" {
t.Fatal("404 之后应该记下这个不可用的昵称")
}
if !strings.Contains(reason, "未注册") {
t.Errorf("记录的原因不对:%q", reason)
}
// 之后再推同一个昵称就该被拦住了
if got := meowNickProblem(nick); got != reason {
t.Errorf("meowNickProblem 应该返回被拦的原因,实际 %q", got)
}
// 两者都不该影响别的昵称
if got := meowBadNickReason("another_nick"); got != "" {
t.Errorf("别的昵称不该被牵连,实际 %q", got)
}
// meowReady 要据此判定推不出去
n := notifySetting{meowEnabled: true, meowNick: nick}
if n.meowReady() {
t.Error("昵称被判过不存在时 meowReady 应该是 false")
}
if n.meowBlockedReason() != reason {
t.Errorf("meowBlockedReason 应返回被拦原因,实际 %q", n.meowBlockedReason())
}
// 4xx / 5xx 里只有 404 会触发拦截,429 和 400 不该记进去
resetMeowBadNick()
for _, body := range []string{`{"status":429,"message":"限流"}`, `{"status":400,"message":"参数错"}`} {
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write([]byte(body))
})
_ = pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0)
if got := meowBadNickReason(nick); got != "" {
t.Errorf("status %s 不该触发拦截,实际记成了 %q", body, got)
}
}
}
// 被拦之后,测试接口要直接拒绝并说清原因,而不是又打一次接口
func TestNotifyTestHandlerMeowBlocked(t *testing.T) {
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
resetMeowBadNick()
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
resetMeowBadNick()
}()
*configDir = t.TempDir()
configFileLocation = filepath.Join(*configDir, configFile)
markMeowBadNick("blocked_nick", meowStatusText(404))
req := httptest.NewRequest(http.MethodPost, "/api/notify/test",
strings.NewReader(`{"channel":"meow","nickname":"blocked_nick"}`))
rec := httptest.NewRecorder()
notifyTestHandler(rec, req)
if rec.Code != 400 {
t.Errorf("被拦的昵称应该返回 400,实际 %d", rec.Code)
}
body := rec.Body.String()
if !strings.Contains(body, "不再重试") {
t.Errorf("提示要说明不会再重试,实际:%s", body)
}
}
// 端到端:sendNotify 要按事件勾选和两个渠道开关正确分发
func TestSendNotifyFanOut(t *testing.T) {
savedConfig := config
resetMeowBadNick()
defer func() {
config = savedConfig
resetMeowBadNick()
}()
var mu sync.Mutex
var hooks []notifyPayload
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var p notifyPayload
_ = json.NewDecoder(r.Body).Decode(&p)
mu.Lock()
hooks = append(hooks, p)
mu.Unlock()
_, _ = w.Write([]byte("ok"))
}))
defer webhook.Close()
var meows []map[string]string
var meowQuery []string
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
var body map[string]string
_ = json.NewDecoder(r.Body).Decode(&body)
mu.Lock()
meows = append(meows, body)
meowQuery = append(meowQuery, r.URL.RawQuery)
mu.Unlock()
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
config.NotifyURL = webhook.URL
config.NotifyToken = "hook-token"
config.NotifyGroup = "777163254"
config.NotifyLiveOn = true
config.MeowEnabled = true
config.MeowNickname = "小小怪"
config.MeowMsgType = "text"
counts := func() (int, int) {
mu.Lock()
defer mu.Unlock()
return len(hooks), len(meows)
}
waitFor := func(tag string, wantHook, wantMeow int) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
h, m := counts()
if h >= wantHook && m >= wantMeow {
return
}
time.Sleep(20 * time.Millisecond)
}
h, m := counts()
t.Fatalf("%s:等超时了,webhook %d 条(期望 %d)、MeoW %d 条(期望 %d)", tag, h, wantHook, m, wantMeow)
}
// 两个渠道都开:应该各收到一条
config.NotifyEnabled = true
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "",
"Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1")
waitFor("两个渠道都开", 1, 1)
mu.Lock()
h := hooks[0]
m := meows[0]
q := meowQuery[0]
mu.Unlock()
if h.Event != notifyEventLiveOn || h.Group != "777163254" || h.UID != 280178 {
t.Errorf("webhook 收到的内容不对:%+v", h)
}
if m["msg"] != "Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1" {
t.Errorf("MeoW 正文不对:%q", m["msg"])
}
if m["title"] != "开播提醒" {
t.Errorf("MeoW 标题不对:%q", m["title"])
}
if m["url"] != "https://live.acfun.cn/1" {
t.Errorf("MeoW 跳转链接不对:%q", m["url"])
}
if !strings.Contains(q, "msgType=text") {
t.Errorf("MeoW 查询参数不对:%q", q)
}
// 关掉 QQ 渠道,只剩 MeoW
config.NotifyEnabled = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "再来一条")
waitFor("只开 MeoW", 1, 2)
// 关掉 MeoW,只剩 QQ
config.NotifyEnabled = true
config.MeowEnabled = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "第三条")
waitFor("只开 QQ", 2, 2)
// 两个都关,一条都不该发
config.NotifyEnabled = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "不该发出")
time.Sleep(300 * time.Millisecond)
if h, m := counts(); h != 2 || m != 2 {
t.Errorf("两个渠道都关掉后不该再推送,实际 webhook %d 条、MeoW %d 条", h, m)
}
// 事件没勾选时也不该发(渠道都开着)
config.NotifyEnabled = true
config.MeowEnabled = true
config.NotifyLiveOn = false
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "事件没勾")
time.Sleep(300 * time.Millisecond)
if h, m := counts(); h != 2 || m != 2 {
t.Errorf("事件没勾选时不该推送,实际 webhook %d 条、MeoW %d 条", h, m)
}
}
func TestNotifyEventTitle(t *testing.T) {
for event, want := range map[string]string{
notifyEventLiveOn: "开播提醒",
notifyEventLiveOff: "下播提醒",
notifyEventRecordStart: "开始录制",
notifyEventMergeDone: "合并完成",
} {
if got := notifyEventTitle(event); got != want {
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
}
}
}
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制
func TestNotifySettingChannelReady(t *testing.T) {
cases := []struct {
name string
n notifySetting
wantQQ, wantM bool
}{
{"都没开", notifySetting{}, false, false},
{"QQ 开了但没填地址", notifySetting{qqEnabled: true}, false, false},
{"QQ 开了且填了地址", notifySetting{qqEnabled: true, url: "http://x/y"}, true, false},
{"QQ 填了地址但没开", notifySetting{url: "http://x/y"}, false, false},
{"MeoW 开了但没昵称", notifySetting{meowEnabled: true}, false, false},
{"MeoW 开了且有昵称", notifySetting{meowEnabled: true, meowNick: "JohnDoe"}, false, true},
{"MeoW 昵称非法", notifySetting{meowEnabled: true, meowNick: "bad/nick"}, false, false},
{"两个都开了", notifySetting{qqEnabled: true, url: "http://x/y", meowEnabled: true, meowNick: "JohnDoe"}, true, true},
}
for _, c := range cases {
if got := c.n.qqReady(); got != c.wantQQ {
t.Errorf("%s:qqReady = %v,期望 %v", c.name, got, c.wantQQ)
}
if got := c.n.meowReady(); got != c.wantM {
t.Errorf("%s:meowReady = %v,期望 %v", c.name, got, c.wantM)
}
}
}
// 老设置文件里没有 notifyEnabled,升级后不能把已经配好的推送静默关掉
func TestNotifyEnabledBackwardCompat(t *testing.T) {
dir := t.TempDir()
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
*configDir = dir
configFileLocation = filepath.Join(dir, configFile)
write := func(content string) {
t.Helper()
if err := os.WriteFile(configFileLocation, []byte(content), 0644); err != nil {
t.Fatal(err)
}
}
// 老配置:完全没提 notifyEnabled,但已经配好了地址
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyLiveOn":true}`)
loadConfig()
if !config.NotifyEnabled {
t.Errorf("老配置里没有 notifyEnabled 时应该默认开启,实际关闭了")
}
if config.NotifyURL == "" || !config.NotifyLiveOn {
t.Errorf("其它字段被破坏:url=%q liveOn=%v", config.NotifyURL, config.NotifyLiveOn)
}
// 显式写了 true
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":true}`)
loadConfig()
if !config.NotifyEnabled {
t.Errorf("显式写 true 时应该是开启")
}
// 显式写了 false,必须真的关掉
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":false}`)
loadConfig()
if config.NotifyEnabled {
t.Errorf("显式写 false 时应该是关闭")
}
}
// 测试接口:没填地址或昵称时给 400,填了就往对应渠道发
func TestNotifyTestHandler(t *testing.T) {
savedConfig := config
savedLocation := configFileLocation
oldDir := configDir
if configDir == nil {
configDir = new(string)
}
defer func() {
config = savedConfig
configFileLocation = savedLocation
configDir = oldDir
}()
*configDir = t.TempDir()
configFileLocation = filepath.Join(*configDir, configFile)
var webhookHits int
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
webhookHits++
_, _ = w.Write([]byte("ok"))
}))
defer webhook.Close()
var meowNick string
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
meowNick = strings.TrimPrefix(r.URL.Path, "/")
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
})
call := func(body string) (int, string) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/api/notify/test", strings.NewReader(body))
rec := httptest.NewRecorder()
notifyTestHandler(rec, req)
return rec.Code, rec.Body.String()
}
// 什么都没配
config.NotifyURL, config.MeowNickname = "", ""
if code, msg := call(`{"channel":"qq"}`); code != 400 || !strings.Contains(msg, "推送地址") {
t.Errorf("没填地址应该 400,实际 %d %s", code, msg)
}
if code, msg := call(`{"channel":"meow"}`); code != 400 || !strings.Contains(msg, "昵称") {
t.Errorf("没填昵称应该 400,实际 %d %s", code, msg)
}
if code, msg := call(`{"channel":"whatever"}`); code != 400 || !strings.Contains(msg, "未知的推送渠道") {
t.Errorf("未知渠道应该 400,实际 %d %s", code, msg)
}
// 用页面上的值直接测 webhook,不需要先保存
code, msg := call(`{"channel":"qq","url":"` + webhook.URL + `","group":"777163254"}`)
if code != 200 || !strings.Contains(msg, `"ok":true`) || !strings.Contains(msg, "HTTP 200") {
t.Errorf("QQ 渠道测试应该成功,实际 %d %s", code, msg)
}
if webhookHits != 1 {
t.Errorf("webhook 应该被调用 1 次,实际 %d 次", webhookHits)
}
// MeoW 渠道
code, msg = call(`{"channel":"meow","nickname":"小小怪","msgType":"html","htmlHeight":300}`)
if code != 200 || !strings.Contains(msg, "status 200") {
t.Errorf("MeoW 渠道测试应该成功,实际 %d %s", code, msg)
}
if meowNick != "小小怪" {
t.Errorf("MeoW 收到的昵称是 %q,期望 小小怪", meowNick)
}
// 昵称带斜杠要被拦下(否则会打到别的路径上去)
if code, _ := call(`{"channel":"meow","nickname":"bad/nick"}`); code != 400 {
t.Errorf("非法昵称应该 400,实际 %d", code)
}
}
+221 -35
View File
@@ -53,7 +53,10 @@ const webuiHTML = `<!DOCTYPE html>
button:active,a.ghost:active{opacity:.8}
button:disabled{opacity:.5}
.hint{font-size:12px;color:var(--sub);margin-top:6px;line-height:1.6}
.hint.bad{color:#d63046}
.hr{height:1px;background:var(--soft);margin:12px -14px}
.ch{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:#333;margin:12px 0 8px}
.ch::after{content:"";flex:1;height:1px;background:var(--soft)}
.row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 0;border-top:1px solid var(--soft)}
.row:first-child{border-top:none;padding-top:2px}
.uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums}
@@ -114,21 +117,44 @@ const webuiHTML = `<!DOCTYPE html>
<div class="hint">录制是 -c copy 直接封装,不转码,四种格式画质完全一样,只是容器不同。正常停止(停止按钮、Ctrl+C)都没问题;如果进程被强杀(任务管理器结束进程、断电),mp4 / flv / mkv 都会整个报废,实测只有 ts 还能播出已经录到的部分。合并出来的成片始终是 mp4。</div>
<div class="hint">同一主播下播后 20 秒内又上播会当成网络波动,接着录同一场,最终只合成一个成片。</div>
<div class="hr"></div>
<div class="grid">
<div class="f"><label>通知推送地址(留空 = 不推送,POST JSON)</label><input type="text" id="notifyUrl" placeholder="例如 http://127.0.0.1:8080/acfunlive/notify"></div>
<div class="f"><label>推送口令(留空 = 不带,作为 Bearer 发送)</label><input type="text" id="notifyToken" placeholder="可选"></div>
<div class="f"><label>通知群号(随推送下发,机器人用它决定发到哪)</label><input type="text" id="notifyGroup" inputmode="numeric" placeholder="例如 777163254,留空则用机器人自己的默认群"></div>
</div>
<div style="margin-top:8px">
<div class="ch">通知推送</div>
<div>
<label class="chk"><input type="checkbox" id="notifyLiveOn">开播</label>
<label class="chk"><input type="checkbox" id="notifyLiveOff">下播</label>
<label class="chk"><input type="checkbox" id="notifyRecordStart">开始录制</label>
<label class="chk"><input type="checkbox" id="notifyMergeDone">合并完成</label>
</div>
<div class="hint">勾选的事件会以 JSON 推送到上面的地址(字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群);推送失败只记警告,不影响录制</div>
<div class="hint">这一行勾的是「推哪些事件」,下面两个渠道共用;推送失败只记警告,不影响录制</div>
<div class="ch">渠道一 · QQ 机器人(webhook)</div>
<div><label class="chk"><input type="checkbox" id="notifyEnabled">启用这个渠道</label></div>
<div class="grid" style="margin-top:8px">
<div class="f"><label>通知推送地址(留空 = 不推送,POST JSON)</label><input type="text" id="notifyUrl" placeholder="例如 http://127.0.0.1:8080/acfunlive/notify"></div>
<div class="f"><label>推送口令(留空 = 不带,作为 Bearer 发送)</label><input type="text" id="notifyToken" placeholder="可选"></div>
<div class="f"><label>通知群号(随推送下发,机器人用它决定发到哪)</label><input type="text" id="notifyGroup" inputmode="numeric" placeholder="例如 777163254,留空则用机器人自己的默认群"></div>
</div>
<div class="hint">推送体是 json,字段 event / uid / name / title / url / file / group / text / time,text 可以直接发给群</div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="testNotify(this)">发送测试通知</button>
<span class="hint" style="margin:0">用上面填写的地址直接测试,不需要先保存</span>
<button class="ghost" onclick="testNotify(this,'qq')">测试连通</button>
<span class="hint" style="margin:0">用上面填写的地址发一条测试推送,不用先保存</span>
</div>
<div class="ch">渠道二 · MeoW</div>
<div><label class="chk"><input type="checkbox" id="meowEnabled">启用这个渠道</label></div>
<div class="grid" style="margin-top:8px">
<div class="f"><label>MeoW 昵称(接收者)</label><input type="text" id="meowNickname" placeholder="你在 MeoW 里的昵称"></div>
<div class="f"><label>消息类型</label><select id="meowMsgType">
<option value="text">text(纯文本)</option>
<option value="html">html(在 App 里渲染)</option>
</select></div>
<div class="f" id="meowHeightRow"><label>HTML 显示高度(像素)</label><input type="text" id="meowHtmlHeight" inputmode="numeric" placeholder="200"></div>
<div class="f"><label>通知图标 URL(可选)</label><input type="text" id="meowImgUrl" placeholder="建议 216×216 的 PNG,留空用 MeoW 默认图标"></div>
</div>
<div class="hint">推给 MeoW 里这个昵称,手机上直接收通知,点开可以跳回直播间;接口有频控(3 秒 1 条、每分钟 15 条、每小时 60 条)</div>
<div class="hint bad" id="meowBlocked" style="display:none"></div>
<div style="margin-top:8px;display:flex;flex-wrap:wrap;gap:8px;align-items:center">
<button class="ghost" onclick="testNotify(this,'meow')">测试连通</button>
<span class="hint" style="margin:0">用上面填写的昵称发一条测试推送,不用先保存</span>
</div>
<div style="margin-top:10px">
<label class="chk"><input type="checkbox" id="webExternal">允许外网访问(关闭时只有本机能打开本页)</label>
@@ -329,10 +355,20 @@ async function refresh(){
setVal("notifyUrl", st.notifyUrl||"");
setVal("notifyToken", st.notifyToken||"");
setVal("notifyGroup", st.notifyGroup||"");
setChk("notifyEnabled", st.notifyEnabled);
setChk("notifyLiveOn", st.notifyLiveOn);
setChk("notifyLiveOff", st.notifyLiveOff);
setChk("notifyRecordStart", st.notifyRecordStart);
setChk("notifyMergeDone", st.notifyMergeDone);
setChk("meowEnabled", st.meowEnabled);
setVal("meowNickname", st.meowNickname||"");
setVal("meowMsgType", st.meowMsgType||"text");
setVal("meowHtmlHeight", st.meowHtmlHeight||200);
setVal("meowImgUrl", st.meowImgUrl||"");
toggleMeowHeight();
var mb = document.getElementById("meowBlocked");
if(st.meowBlocked){ mb.style.display = ""; mb.textContent = "当前推不出去:" + st.meowBlocked; }
else mb.style.display = "none";
setChk("webExternal", st.webExternal);
setChk("autoMerge", st.autoMerge);
setChk("mergeDelVideo", st.mergeDelVideo);
@@ -412,6 +448,7 @@ async function saveSettings(b){
mergeSubdir: document.getElementById("mergeSubdir").value.trim(),
ffmpegPath: document.getElementById("ffmpegPath").value.trim(),
recordFormat: document.getElementById("recordFormat").value,
notifyEnabled: document.getElementById("notifyEnabled").checked,
notifyUrl: document.getElementById("notifyUrl").value.trim(),
notifyToken: document.getElementById("notifyToken").value.trim(),
notifyGroup: document.getElementById("notifyGroup").value.trim(),
@@ -419,17 +456,28 @@ async function saveSettings(b){
notifyLiveOff: document.getElementById("notifyLiveOff").checked,
notifyRecordStart: document.getElementById("notifyRecordStart").checked,
notifyMergeDone: document.getElementById("notifyMergeDone").checked,
meowEnabled: document.getElementById("meowEnabled").checked,
meowNickname: document.getElementById("meowNickname").value.trim(),
meowMsgType: document.getElementById("meowMsgType").value,
meowHtmlHeight: parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0,
meowImgUrl: document.getElementById("meowImgUrl").value.trim(),
webExternal: document.getElementById("webExternal").checked
});
if(ok) showMsg(true, "已保存");
if(b) b.disabled = false;
}
// 勾选类设置修改后立即保存,避免被定时刷新还原
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone"].forEach(function(id){
["autoMerge","mergeDelVideo","mergeDelDanmu","webExternal","notifyEnabled","notifyLiveOn","notifyLiveOff","notifyRecordStart","notifyMergeDone","meowEnabled"].forEach(function(id){
document.getElementById(id).addEventListener("change", function(){ saveSettings(null); });
});
// 下拉框改完也立即保存
document.getElementById("recordFormat").addEventListener("change", function(){ saveSettings(null); });
// HTML 高度只对 html 类型有意义,选 text 时收起来
function toggleMeowHeight(){
var isHTML = document.getElementById("meowMsgType").value === "html";
document.getElementById("meowHeightRow").style.display = isHTML ? "" : "none";
}
document.getElementById("meowMsgType").addEventListener("change", function(){ toggleMeowHeight(); saveSettings(null); });
var fileList = [];
function fmtSize(n){
if(!n) return "0 B";
@@ -598,19 +646,27 @@ function syncMergeState(st){
if(st.mergeErr) showMsg(false, "合并失败:" + st.mergeErr);
else showMsg(true, "手动合并结束,结果见运行日志");
}
async function testNotify(b){
async function testNotify(b, channel){
if(b) b.disabled = true;
var r = null;
var body = {channel: channel || "qq"};
if(body.channel === "meow"){
body.nickname = document.getElementById("meowNickname").value.trim();
body.msgType = document.getElementById("meowMsgType").value;
body.imgUrl = document.getElementById("meowImgUrl").value.trim();
body.htmlHeight = parseInt(document.getElementById("meowHtmlHeight").value, 10) || 0;
}else{
body.url = document.getElementById("notifyUrl").value.trim();
body.token = document.getElementById("notifyToken").value.trim();
body.group = document.getElementById("notifyGroup").value.trim();
}
var r = null, d = null;
try{
r = await api("/api/notify/test", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify({
url: document.getElementById("notifyUrl").value.trim(),
token: document.getElementById("notifyToken").value.trim(),
group: document.getElementById("notifyGroup").value.trim()
})});
r = await api("/api/notify/test", {method:"POST", headers:{"Content-Type":"application/json"}, body: JSON.stringify(body)});
if(r && r.ok) d = await r.json();
}catch(e){}
if(r && r.status === 401){ showLogin("管理口令已失效,请重新输入"); if(b) b.disabled = false; return; }
if(r && r.ok) showMsg(true, "测试通知已发送,请查看是否收到");
else showMsg(false, "发送失败:" + (r ? await r.text() : "连接失败"));
if(r && r.ok) showMsg(true, "测试成功:" + ((d && d.detail) ? d.detail : "推送已发出"));
else showMsg(false, "测试失败:" + (r ? await r.text() : "连接失败"));
if(b) b.disabled = false;
}
async function addUid(){
@@ -686,6 +742,7 @@ type webStatus struct {
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
@@ -693,6 +750,12 @@ type webStatus struct {
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
MeowBlocked string `json:"meowBlocked"` // MeoW 推不出去的原因,空字符串表示正常
MergeBusy bool `json:"mergeBusy"` // 手动合并是否正在进行
MergeOutName string `json:"mergeOutName"` // 正在进行的手动合并的输出文件名
MergeErr string `json:"mergeErr"` // 最近一次手动合并的错误
@@ -739,6 +802,7 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
FFmpegPath: config.FFmpegPath,
RecordFormat: normalizeRecordFormat(config.RecordFormat),
DefaultBitrate: config.DefaultBitrate,
NotifyEnabled: config.NotifyEnabled,
NotifyURL: config.NotifyURL,
NotifyToken: config.NotifyToken,
NotifyGroup: config.NotifyGroup,
@@ -746,6 +810,12 @@ func statusHandler(w http.ResponseWriter, r *http.Request) {
NotifyLiveOff: config.NotifyLiveOff,
NotifyRecordStart: config.NotifyRecordStart,
NotifyMergeDone: config.NotifyMergeDone,
MeowEnabled: config.MeowEnabled,
MeowNickname: config.MeowNickname,
MeowMsgType: normalizeMeowMsgType(config.MeowMsgType),
MeowHTMLHeight: clampMeowHTMLHeight(config.MeowHTMLHeight),
MeowImgURL: config.MeowImgURL,
MeowBlocked: meowStatusProblem(),
MergeBusy: mergeBusy,
MergeOutName: mergeOut,
MergeErr: mergeErr,
@@ -809,6 +879,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
MergeSubdir string `json:"mergeSubdir"`
FFmpegPath string `json:"ffmpegPath"`
RecordFormat string `json:"recordFormat"`
NotifyEnabled bool `json:"notifyEnabled"`
NotifyURL string `json:"notifyUrl"`
NotifyToken string `json:"notifyToken"`
NotifyGroup string `json:"notifyGroup"`
@@ -816,6 +887,11 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
NotifyLiveOff bool `json:"notifyLiveOff"`
NotifyRecordStart bool `json:"notifyRecordStart"`
NotifyMergeDone bool `json:"notifyMergeDone"`
MeowEnabled bool `json:"meowEnabled"`
MeowNickname string `json:"meowNickname"`
MeowMsgType string `json:"meowMsgType"`
MeowHTMLHeight int `json:"meowHtmlHeight"`
MeowImgURL string `json:"meowImgUrl"`
}
err := json.NewDecoder(r.Body).Decode(&req)
if err != nil {
@@ -875,6 +951,25 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, msg, http.StatusBadRequest)
return
}
meowNickname := strings.TrimSpace(req.MeowNickname)
if msg := checkMeowNickname(meowNickname, true); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
meowMsgType := strings.ToLower(strings.TrimSpace(req.MeowMsgType))
if meowMsgType == "" {
meowMsgType = meowMsgTypeText
}
if meowMsgType != meowMsgTypeText && meowMsgType != meowMsgTypeHTML {
http.Error(w, "MeoW 消息类型只能是 text 或 html:"+meowMsgType, http.StatusBadRequest)
return
}
if req.MeowHTMLHeight < 0 || req.MeowHTMLHeight > maxMeowHTMLHeight {
http.Error(w, fmt.Sprintf("MeoW 的 HTML 显示高度只能是 0(用默认 %d)到 %d 之间的整数",
defaultMeowHTMLHeight, maxMeowHTMLHeight), http.StatusBadRequest)
return
}
meowImgURL := strings.TrimSpace(req.MeowImgURL)
streamers.Lock()
config.Directory = req.Directory
@@ -890,6 +985,7 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.MergeSubdir = mergeSubdir
config.FFmpegPath = strings.TrimSpace(req.FFmpegPath)
config.RecordFormat = recordFormat
config.NotifyEnabled = req.NotifyEnabled
config.NotifyURL = notifyURL
config.NotifyToken = strings.TrimSpace(req.NotifyToken)
config.NotifyGroup = strings.TrimSpace(req.NotifyGroup)
@@ -897,6 +993,11 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
config.NotifyLiveOff = req.NotifyLiveOff
config.NotifyRecordStart = req.NotifyRecordStart
config.NotifyMergeDone = req.NotifyMergeDone
config.MeowEnabled = req.MeowEnabled
config.MeowNickname = meowNickname
config.MeowMsgType = meowMsgType
config.MeowHTMLHeight = clampMeowHTMLHeight(req.MeowHTMLHeight)
config.MeowImgURL = meowImgURL
// 修改了子文件夹名后确保新文件夹存在
if err := os.MkdirAll(recordingDir(), 0755); err != nil {
lPrintWarnf("创建文件夹 %s 失败:%v", recordingDir(), err)
@@ -918,8 +1019,10 @@ func webConfigHandler(w http.ResponseWriter, r *http.Request) {
lPrintf("通过 web UI 修改了设置:目录 %q、自动合并 %v、合并删视频 %v、合并删弹幕 %v、成片目录 %q、合并码率 %d、默认下载码率 %d、外网访问 %v、录播子文件夹 %q、成片子文件夹 %q、ffmpeg 路径 %q、录播格式 %q",
req.Directory, req.AutoMerge, req.MergeDelVideo, req.MergeDelDanmu, req.MergeOutputDir,
req.MergeBitrate, req.DefaultBitrate, req.WebExternal, recordSubdir, mergeSubdir, config.FFmpegPath, recordFormat)
lPrintf("推送设置:地址 %q、群号 %q、开播 %v、下播 %v、开始录制 %v、合并完成 %v",
notifyURL, config.NotifyGroup, req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone)
lPrintf("推送设置:QQ 机器人渠道 %v(地址 %q、群号 %q)、MeoW 渠道 %v(昵称 %q、类型 %s、高度 %d);事件 开播 %v、下播 %v、开始录制 %v、合并完成 %v",
req.NotifyEnabled, notifyURL, config.NotifyGroup, req.MeowEnabled, meowNickname, meowMsgType,
clampMeowHTMLHeight(req.MeowHTMLHeight),
req.NotifyLiveOn, req.NotifyLiveOff, req.NotifyRecordStart, req.NotifyMergeDone)
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
@@ -1016,24 +1119,42 @@ func webStreamerRemoveHandler(w http.ResponseWriter, r *http.Request) {
}
// 发送一条测试通知,验证推送地址、群号和口令是否配置正确
// 测试某个推送渠道能不能连通。
// 页面上填的值优先,留空的项用已经保存的配置补齐,所以改完不用先保存也能测
func notifyTestHandler(w http.ResponseWriter, r *http.Request) {
// 允许直接用设置区里还没保存的地址测试,没传就用已经保存的配置
var req struct {
URL string `json:"url"`
Token string `json:"token"`
Group string `json:"group"`
Channel string `json:"channel"` // qq(默认)或 meow
URL string `json:"url"`
Token string `json:"token"`
Group string `json:"group"`
Nickname string `json:"nickname"`
MsgType string `json:"msgType"`
ImgURL string `json:"imgUrl"`
Height int `json:"htmlHeight"`
}
_ = json.NewDecoder(r.Body).Decode(&req)
addr := strings.TrimSpace(req.URL)
token := strings.TrimSpace(req.Token)
group := strings.TrimSpace(req.Group)
if addr == "" {
n := currentNotifySetting()
addr, token = n.url, n.token
switch strings.ToLower(strings.TrimSpace(req.Channel)) {
case "", "qq", "webhook":
testNotifyQQ(w, req.URL, req.Token, req.Group)
case "meow":
testNotifyMeow(w, req.Nickname, req.MsgType, req.ImgURL, req.Height)
default:
http.Error(w, "未知的推送渠道:"+req.Channel, http.StatusBadRequest)
}
if group == "" {
group = currentNotifySetting().group
}
// 给 webhook 发一条测试推送
func testNotifyQQ(w http.ResponseWriter, addr, token, group string) {
n := currentNotifySetting()
if addr = strings.TrimSpace(addr); addr == "" {
addr = n.url
}
if token = strings.TrimSpace(token); token == "" {
token = n.token
}
if group = strings.TrimSpace(group); group == "" {
group = n.group
}
if addr == "" {
http.Error(w, "还没有填写通知推送地址", http.StatusBadRequest)
@@ -1051,15 +1172,80 @@ func notifyTestHandler(w http.ResponseWriter, r *http.Request) {
Text: "这是一条测试通知,收到就说明推送地址和口令都配置正确",
Time: getLogTime(),
}
if err := postNotify(addr, token, p); err != nil {
start := time.Now()
code, err := postNotify(addr, token, p)
if err != nil {
lPrintWarnf("推送测试通知失败:%v", err)
http.Error(w, "推送失败:"+err.Error(), http.StatusBadGateway)
return
}
lPrintln("测试通知推送成功:" + addr)
writeTestResult(w, fmt.Sprintf("webhook 返回 HTTP %d,用时 %s。地址和口令没问题,能不能收到还要看机器人那边有没有正常处理",
code, elapsedText(time.Since(start))))
}
// 给 MeoW 发一条测试推送
func testNotifyMeow(w http.ResponseWriter, nick, msgType, imgURL string, height int) {
n := currentNotifySetting()
if nick = strings.TrimSpace(nick); nick == "" {
nick = n.meowNick
}
if imgURL = strings.TrimSpace(imgURL); imgURL == "" {
imgURL = n.meowImg
}
if strings.TrimSpace(msgType) == "" {
msgType = n.meowMsgType
}
if height <= 0 {
height = n.meowHTML
}
if nick == "" {
http.Error(w, "还没有填写 MeoW 昵称", http.StatusBadRequest)
return
}
if msg := checkMeowNickname(nick, false); msg != "" {
http.Error(w, msg, http.StatusBadRequest)
return
}
// 已经被 MeoW 判过「未注册」的昵称不再重试:官方提示一天内累计 3 次输错昵称就封 IP
if reason := meowBadNickReason(nick); reason != "" {
http.Error(w, fmt.Sprintf("昵称 %s 之前已被 MeoW 判定为%s,为避免 IP 被封不再重试。"+
"确认昵称没填错(或换一个昵称重新保存)就能恢复,重启程序也会清掉这个记录", nick, reason),
http.StatusBadRequest)
return
}
msgType = normalizeMeowMsgType(msgType)
title := "测试推送"
plain := "这是一条测试通知,收到就说明 MeoW 渠道配置正确"
content := plain
if msgType == meowMsgTypeHTML {
content = meowHTMLText(title + "\n" + plain)
}
start := time.Now()
if err := pushMeow(nick, title, content, "", imgURL, msgType, height); err != nil {
lPrintWarnf("推送测试通知到 MeoW 失败:%v", err)
detail := "推送失败:" + err.Error()
if meowBadNickReason(nick) != "" {
detail += "。MeoW 一天内累计 3 次收到不存在的昵称就会封 IP,这里已经暂停向这个昵称推送"
}
http.Error(w, detail, http.StatusBadGateway)
return
}
lPrintf("测试通知推送成功(MeoW):%s", nick)
writeTestResult(w, fmt.Sprintf("MeoW 返回 status 200,用时 %s。手机上应该已经收到通知(昵称 %s)",
elapsedText(time.Since(start)), nick))
}
// 测试结果,detail 会显示在页面的提示里
func writeTestResult(w http.ResponseWriter, detail string) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte("true"))
_ = json.NewEncoder(w).Encode(map[string]any{"ok": true, "detail": detail})
}
func elapsedText(d time.Duration) string {
return fmt.Sprintf("%.2f 秒", d.Seconds())
}
// 显示日志,支持 ?since=<偏移量> 只返回新增的部分,避免每 3 秒全量传输