通知推送拆成两个渠道: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
+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
}
// 检查设置里的推送地址,返回错误信息,空字符串表示通过