通知推送拆成两个渠道: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 全干净。
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
// 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()
|
||||
}
|
||||
Reference in New Issue
Block a user