// 开播、下播、开始录制、合并完成等事件的推送 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 { url string token string group string liveOn bool liveOff bool recordStart bool mergeDone bool } func currentNotifySetting() notifySetting { streamers.RLock() defer streamers.RUnlock() return notifySetting{ url: strings.TrimSpace(config.NotifyURL), token: strings.TrimSpace(config.NotifyToken), group: strings.TrimSpace(config.NotifyGroup), 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 } // 推送一个事件。没配置推送地址或没开启该事件时什么都不做, // 推送在后台 goroutine 里进行,失败只打警告,不会影响录制 func sendNotify(event string, uid int, name, title, url, file, text string) { n := currentNotifySetting() if n.url == "" || !n.enabled(event) { return } p := notifyPayload{ Event: event, UID: uid, Name: name, Title: title, URL: url, File: file, Group: n.group, 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 } lPrintf("已推送%s通知:%s", event, p.Text) }) } // 把事件 POST 到 webhook func postNotify(addr, token string, p notifyPayload) error { data, err := json.Marshal(p) if err != nil { return err } req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data)) if err != nil { return 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 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 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 "" }