// 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("
" + esc + "
") first = false case isHTTPURL(line): b.WriteString(``) default: b.WriteString("" + esc + "
") } } return b.String() }