// 开播、下播、开始录制、合并完成等事件的推送 package main import ( "bytes" "encoding/json" "fmt" "io" "net/http" "strings" "sync" "time" ) // 推送的事件名,外部(如 NoneBot 机器人)按这个字段区分事件 const ( notifyEventLiveOn = "liveOn" notifyEventLiveOff = "liveOff" notifyEventRecordStart = "recordStart" notifyEventMergeDone = "mergeDone" notifyEventMergeFail = "mergeFail" ) // 推送到 webhook 的请求体 type notifyPayload struct { Event string `json:"event"` // 事件名:liveOn、liveOff、recordStart、mergeDone、mergeFail 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 mergeFail 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, mergeFail: config.NotifyMergeFail, } } // 该事件是否开启了推送 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 case notifyEventMergeFail: return n.mergeFail } 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) }) } } // 合并失败时推送。nil、「弹幕文件里没有弹幕」和被手动停止都不算失败,不会推送。 // fileName 用于正文里标出是哪一场,拿不到就留空 func notifyMergeFailure(uid int, fileName string, err error) { if err == nil || err == errNoDanmu || err == errJobStopped { return } text := "合并失败" if uid > 0 { text += "(" + longID(uid) + " 的录播和弹幕)" } else if fileName != "" { text += "(" + fileName + ")" } text += "\n原因:" + err.Error() sendNotify(notifyEventMergeFail, uid, nameForLog(uid), "", "", fileName, 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 "" }