用户要求两件事:① 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 全干净。
243 lines
7.2 KiB
Go
243 lines
7.2 KiB
Go
// 开播、下播、开始录制、合并完成等事件的推送
|
||
package main
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
)
|
||
|
||
// 推送的事件名,外部(如 NoneBot 机器人)按这个字段区分事件
|
||
const (
|
||
notifyEventLiveOn = "liveOn"
|
||
notifyEventLiveOff = "liveOff"
|
||
notifyEventRecordStart = "recordStart"
|
||
notifyEventMergeDone = "mergeDone"
|
||
)
|
||
|
||
// 推送到 webhook 的请求体
|
||
type notifyPayload struct {
|
||
Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone
|
||
UID int `json:"uid"` // 主播 uid
|
||
Name string `json:"name"` // 主播名字
|
||
Title string `json:"title"` // 直播标题(下播和合并完成时可能为空)
|
||
URL string `json:"url"` // 直播间地址
|
||
File string `json:"file"` // 相关文件路径(录制开始时的视频文件、合并完成时的成片)
|
||
Group string `json:"group"` // web UI 里配置的通知群号,机器人可以直接用它决定发到哪个群
|
||
Text string `json:"text"` // 可以直接发出去的文本,机器人拿到后原样发送即可
|
||
Time string `json:"time"` // 事件发生的时刻
|
||
}
|
||
|
||
// 推送用的 http 客户端,超时短一点,避免推送把任务拖住
|
||
var notifyClient = &http.Client{Timeout: 10 * time.Second}
|
||
|
||
// 已经推送过开播通知的 liveID,避免重连或改设置时重复推送
|
||
var notifiedLive struct {
|
||
sync.Mutex
|
||
ids map[string]struct{}
|
||
}
|
||
|
||
// 首次见到这个 liveID 时返回 true
|
||
func notifiedLiveOnce(liveID string) bool {
|
||
if liveID == "" {
|
||
return false
|
||
}
|
||
notifiedLive.Lock()
|
||
defer notifiedLive.Unlock()
|
||
if notifiedLive.ids == nil {
|
||
notifiedLive.ids = make(map[string]struct{})
|
||
}
|
||
if _, ok := notifiedLive.ids[liveID]; ok {
|
||
return false
|
||
}
|
||
notifiedLive.ids[liveID] = struct{}{}
|
||
// 长时间运行后简单地清一次,避免无限增长
|
||
if len(notifiedLive.ids) > 4096 {
|
||
notifiedLive.ids = map[string]struct{}{liveID: {}}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// 推送设置的一份快照,避免在推送过程中读到正在被修改的配置
|
||
type notifySetting struct {
|
||
// 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
|
||
mergeDone bool
|
||
}
|
||
|
||
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,
|
||
mergeDone: config.NotifyMergeDone,
|
||
}
|
||
}
|
||
|
||
// 该事件是否开启了推送
|
||
func (n notifySetting) enabled(event string) bool {
|
||
switch event {
|
||
case notifyEventLiveOn:
|
||
return n.liveOn
|
||
case notifyEventLiveOff:
|
||
return n.liveOff
|
||
case notifyEventRecordStart:
|
||
return n.recordStart
|
||
case notifyEventMergeDone:
|
||
return n.mergeDone
|
||
}
|
||
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.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{
|
||
Event: event,
|
||
UID: uid,
|
||
Name: name,
|
||
Title: title,
|
||
URL: url,
|
||
File: file,
|
||
Group: n.group,
|
||
Text: text,
|
||
Time: getLogTime(),
|
||
}
|
||
|
||
// 推送地址和口令可能在推送过程中被改掉,这里带进闭包,保证用的是同一次配置
|
||
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 = ""
|
||
}
|
||
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,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节)
|
||
func postNotify(addr, token string, p notifyPayload) (int, error) {
|
||
data, err := json.Marshal(p)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data))
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||
if token != "" {
|
||
req.Header.Set("Authorization", "Bearer "+token)
|
||
req.Header.Set("X-Auth-Token", token)
|
||
}
|
||
|
||
resp, err := notifyClient.Do(req)
|
||
if err != nil {
|
||
return 0, err
|
||
}
|
||
defer resp.Body.Close()
|
||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
|
||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||
return resp.StatusCode, fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
||
}
|
||
return resp.StatusCode, nil
|
||
}
|
||
|
||
// 检查设置里的推送地址,返回错误信息,空字符串表示通过
|
||
func checkNotifyURL(addr string) string {
|
||
addr = strings.TrimSpace(addr)
|
||
if addr == "" {
|
||
return ""
|
||
}
|
||
if !strings.HasPrefix(addr, "http://") && !strings.HasPrefix(addr, "https://") {
|
||
return "推送地址必须以 http:// 或 https:// 开头:" + addr
|
||
}
|
||
return ""
|
||
}
|