Files
acfunlive-remake/meow.go
T
tamakyi 8528499a69 通知推送拆成两个渠道: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 全干净。
2026-09-25 13:42:49 +08:00

296 lines
8.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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("<p><b>" + esc + "</b></p>")
first = false
case isHTTPURL(line):
b.WriteString(`<p><a href="` + esc + `">` + esc + `</a></p>`)
default:
b.WriteString("<p>" + esc + "</p>")
}
}
return b.String()
}