通知推送拆成两个渠道: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:
+594
@@ -0,0 +1,594 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 把 MeoW 的接口地址临时换成本地测试服务器
|
||||
func withMeowServer(t *testing.T, h http.HandlerFunc) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(h)
|
||||
old := meowAPIBase
|
||||
meowAPIBase = srv.URL + "/"
|
||||
t.Cleanup(func() {
|
||||
meowAPIBase = old
|
||||
srv.Close()
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestCheckMeowNickname(t *testing.T) {
|
||||
cases := []struct {
|
||||
nick string
|
||||
allow bool
|
||||
wantOK bool
|
||||
}{
|
||||
{"JohnDoe", false, true},
|
||||
{"小小怪", false, true},
|
||||
{" JohnDoe ", false, true}, // 前后空白会被去掉
|
||||
{"", true, true}, // 允许留空
|
||||
{"", false, false}, // 要推送时不能为空
|
||||
{"John/Doe", false, false},
|
||||
{`John\Doe`, false, false},
|
||||
{"John?Doe", false, false},
|
||||
{"John#Doe", false, false},
|
||||
{strings.Repeat("a", 65), false, false},
|
||||
{strings.Repeat("a", 64), false, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got := checkMeowNickname(c.nick, c.allow)
|
||||
if (got == "") != c.wantOK {
|
||||
t.Errorf("checkMeowNickname(%q, %v) = %q,期望通过=%v", c.nick, c.allow, got, c.wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeMeowMsgTypeAndHeight(t *testing.T) {
|
||||
for _, c := range []struct{ in, want string }{
|
||||
{"", meowMsgTypeText},
|
||||
{"text", meowMsgTypeText},
|
||||
{"TEXT", meowMsgTypeText},
|
||||
{" html ", meowMsgTypeHTML},
|
||||
{"HTML", meowMsgTypeHTML},
|
||||
{"markdown", meowMsgTypeText}, // 认不出来就回退
|
||||
} {
|
||||
if got := normalizeMeowMsgType(c.in); got != c.want {
|
||||
t.Errorf("normalizeMeowMsgType(%q) = %q,期望 %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
for _, c := range []struct{ in, want int }{
|
||||
{0, defaultMeowHTMLHeight},
|
||||
{-5, defaultMeowHTMLHeight},
|
||||
{10, minMeowHTMLHeight},
|
||||
{200, 200},
|
||||
{99999, maxMeowHTMLHeight},
|
||||
} {
|
||||
if got := clampMeowHTMLHeight(c.in); got != c.want {
|
||||
t.Errorf("clampMeowHTMLHeight(%d) = %d,期望 %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeowURL(t *testing.T) {
|
||||
got := meowURL("小小怪", meowMsgTypeText, 200)
|
||||
// text 类型不该带上 htmlHeight
|
||||
if strings.Contains(got, "htmlHeight") {
|
||||
t.Errorf("text 类型不该带 htmlHeight:%s", got)
|
||||
}
|
||||
if !strings.Contains(got, "msgType=text") {
|
||||
t.Errorf("缺少 msgType:%s", got)
|
||||
}
|
||||
// 中文昵称必须转义,不能原样进地址
|
||||
if strings.Contains(got, "小小怪") {
|
||||
t.Errorf("昵称没做 URL 转义:%s", got)
|
||||
}
|
||||
if !strings.HasSuffix(got, "/%E5%B0%8F%E5%B0%8F%E6%80%AA?msgType=text") {
|
||||
t.Errorf("昵称转义结果不对:%s", got)
|
||||
}
|
||||
|
||||
got = meowURL("JohnDoe", meowMsgTypeHTML, 350)
|
||||
if !strings.Contains(got, "msgType=html") || !strings.Contains(got, "htmlHeight=350") {
|
||||
t.Errorf("html 类型的参数不对:%s", got)
|
||||
}
|
||||
// 超出范围的高度要夹住
|
||||
if got := meowURL("JohnDoe", meowMsgTypeHTML, 99999); !strings.Contains(got, "htmlHeight=2000") {
|
||||
t.Errorf("高度没夹住:%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMeowHTMLText(t *testing.T) {
|
||||
got := meowHTMLText("Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1\n<i>不该被解析</i>")
|
||||
for _, want := range []string{
|
||||
"<p><b>Xsphere 开播了</b></p>", // 第一行加粗
|
||||
"<p>今天画点啥</p>", // 普通行
|
||||
`<p><a href="https://live.acfun.cn/1">https://live.acfun.cn/1</a></p>`, // 链接可点
|
||||
"<i>不该被解析</i>", // 内容要转义
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("HTML 里缺少 %q,实际:%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, "<i>") {
|
||||
t.Errorf("内容没被转义,存在注入风险:%s", got)
|
||||
}
|
||||
if got := meowHTMLText(" "); got != "" {
|
||||
t.Errorf("空内容应该得到空串,实际 %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功路径:确认请求本身拼对了,并且能正确解析文档里的两种响应字段(message / msg)
|
||||
func TestPushMeowSuccess(t *testing.T) {
|
||||
var gotPath, gotQuery, gotMethod, gotType string
|
||||
var gotBody map[string]string
|
||||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath, gotQuery, gotMethod = r.URL.Path, r.URL.RawQuery, r.Method
|
||||
gotType = r.Header.Get("Content-Type")
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||
})
|
||||
|
||||
err := pushMeow("小小怪", "开播提醒", "Xsphere 开播了", "https://live.acfun.cn/1", "https://x/icon.png",
|
||||
meowMsgTypeHTML, 350)
|
||||
if err != nil {
|
||||
t.Fatalf("推送失败:%v", err)
|
||||
}
|
||||
if gotMethod != http.MethodPost {
|
||||
t.Errorf("方法应该是 POST,实际 %s", gotMethod)
|
||||
}
|
||||
if gotPath != "/小小怪" {
|
||||
t.Errorf("路径不对:%q", gotPath)
|
||||
}
|
||||
if !strings.Contains(gotQuery, "msgType=html") || !strings.Contains(gotQuery, "htmlHeight=350") {
|
||||
t.Errorf("查询参数不对:%q", gotQuery)
|
||||
}
|
||||
if !strings.HasPrefix(gotType, "application/json") {
|
||||
t.Errorf("Content-Type 不对:%q", gotType)
|
||||
}
|
||||
for k, want := range map[string]string{
|
||||
"msg": "Xsphere 开播了",
|
||||
"title": "开播提醒",
|
||||
"url": "https://live.acfun.cn/1",
|
||||
"imgUrl": "https://x/icon.png",
|
||||
} {
|
||||
if gotBody[k] != want {
|
||||
t.Errorf("body 里的 %s = %q,期望 %q", k, gotBody[k], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 空字段不能进 body:body 里的 title 优先级比路径参数高,传空串会把默认标题顶掉
|
||||
func TestPushMeowOmitsEmptyFields(t *testing.T) {
|
||||
var gotBody map[string]any
|
||||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
raw, _ := io.ReadAll(r.Body)
|
||||
_ = json.Unmarshal(raw, &gotBody)
|
||||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||
})
|
||||
|
||||
if err := pushMeow("JohnDoe", "", "只有正文", "", "", meowMsgTypeText, 0); err != nil {
|
||||
t.Fatalf("推送失败:%v", err)
|
||||
}
|
||||
if len(gotBody) != 1 || gotBody["msg"] != "只有正文" {
|
||||
t.Errorf("应该只发 msg 一个字段,实际 %v", gotBody)
|
||||
}
|
||||
}
|
||||
|
||||
// 失败路径:文档里 HTTP 状态一律是 200,成败只能看 body 里的 status
|
||||
func TestPushMeowStatusCodes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{"昵称未注册", `{"status":404,"message":"昵称未注册"}`, "昵称未注册"},
|
||||
{"频率超限", `{"status":429,"msg":"too many requests"}`, "频率限制"},
|
||||
{"内容被拦", `{"status":403,"message":"forbidden"}`, "策略"},
|
||||
{"参数错误", `{"status":400,"message":"bad request"}`, "参数错误"},
|
||||
{"服务器错误", `{"status":500,"msg":"服务器错误"}`, "MeoW 服务器错误"},
|
||||
{"不是 json", `<!doctype html><html>502</html>`, "不是合法 json"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(c.body))
|
||||
})
|
||||
err := pushMeow("JohnDoe", "标题", "正文", "", "", meowMsgTypeText, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("status 不是 200 时必须报错")
|
||||
}
|
||||
if !strings.Contains(err.Error(), c.want) {
|
||||
t.Errorf("错误信息里应包含 %q,实际:%v", c.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushMeowRejectsBadInput(t *testing.T) {
|
||||
// 昵称不合法时必须在发请求前就拦下来
|
||||
if err := pushMeow("bad/nick", "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
|
||||
t.Error("昵称带斜杠应该报错")
|
||||
}
|
||||
if err := pushMeow("JohnDoe", "标题", " ", "", "", meowMsgTypeText, 0); err == nil {
|
||||
t.Error("内容为空应该报错")
|
||||
}
|
||||
}
|
||||
|
||||
// MeoW 对错误昵称记数并封 IP,所以 404 之后必须停止重试,否则事件反复触发会把用户 IP 封掉
|
||||
func TestMeowBadNickGuard(t *testing.T) {
|
||||
const nick = "guard_test_nick"
|
||||
resetMeowBadNick()
|
||||
t.Cleanup(resetMeowBadNick)
|
||||
|
||||
if got := meowBadNickReason(nick); got != "" {
|
||||
t.Fatalf("一开始不该有记录,实际 %q", got)
|
||||
}
|
||||
if got := meowNickProblem(nick); got != "" {
|
||||
t.Fatalf("没被封之前应该没问题,实际 %q", got)
|
||||
}
|
||||
|
||||
// MeoW 返回 404 后,这个昵称要被记住
|
||||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"status":404,"message":"昵称未注册"}`))
|
||||
})
|
||||
if err := pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0); err == nil {
|
||||
t.Fatal("404 时应该报错")
|
||||
}
|
||||
reason := meowBadNickReason(nick)
|
||||
if reason == "" {
|
||||
t.Fatal("404 之后应该记下这个不可用的昵称")
|
||||
}
|
||||
if !strings.Contains(reason, "未注册") {
|
||||
t.Errorf("记录的原因不对:%q", reason)
|
||||
}
|
||||
// 之后再推同一个昵称就该被拦住了
|
||||
if got := meowNickProblem(nick); got != reason {
|
||||
t.Errorf("meowNickProblem 应该返回被拦的原因,实际 %q", got)
|
||||
}
|
||||
// 两者都不该影响别的昵称
|
||||
if got := meowBadNickReason("another_nick"); got != "" {
|
||||
t.Errorf("别的昵称不该被牵连,实际 %q", got)
|
||||
}
|
||||
// meowReady 要据此判定推不出去
|
||||
n := notifySetting{meowEnabled: true, meowNick: nick}
|
||||
if n.meowReady() {
|
||||
t.Error("昵称被判过不存在时 meowReady 应该是 false")
|
||||
}
|
||||
if n.meowBlockedReason() != reason {
|
||||
t.Errorf("meowBlockedReason 应返回被拦原因,实际 %q", n.meowBlockedReason())
|
||||
}
|
||||
|
||||
// 4xx / 5xx 里只有 404 会触发拦截,429 和 400 不该记进去
|
||||
resetMeowBadNick()
|
||||
for _, body := range []string{`{"status":429,"message":"限流"}`, `{"status":400,"message":"参数错"}`} {
|
||||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
})
|
||||
_ = pushMeow(nick, "标题", "正文", "", "", meowMsgTypeText, 0)
|
||||
if got := meowBadNickReason(nick); got != "" {
|
||||
t.Errorf("status %s 不该触发拦截,实际记成了 %q", body, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 被拦之后,测试接口要直接拒绝并说清原因,而不是又打一次接口
|
||||
func TestNotifyTestHandlerMeowBlocked(t *testing.T) {
|
||||
savedConfig := config
|
||||
savedLocation := configFileLocation
|
||||
oldDir := configDir
|
||||
if configDir == nil {
|
||||
configDir = new(string)
|
||||
}
|
||||
resetMeowBadNick()
|
||||
defer func() {
|
||||
config = savedConfig
|
||||
configFileLocation = savedLocation
|
||||
configDir = oldDir
|
||||
resetMeowBadNick()
|
||||
}()
|
||||
*configDir = t.TempDir()
|
||||
configFileLocation = filepath.Join(*configDir, configFile)
|
||||
|
||||
markMeowBadNick("blocked_nick", meowStatusText(404))
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/notify/test",
|
||||
strings.NewReader(`{"channel":"meow","nickname":"blocked_nick"}`))
|
||||
rec := httptest.NewRecorder()
|
||||
notifyTestHandler(rec, req)
|
||||
|
||||
if rec.Code != 400 {
|
||||
t.Errorf("被拦的昵称应该返回 400,实际 %d", rec.Code)
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "不再重试") {
|
||||
t.Errorf("提示要说明不会再重试,实际:%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// 端到端:sendNotify 要按事件勾选和两个渠道开关正确分发
|
||||
func TestSendNotifyFanOut(t *testing.T) {
|
||||
savedConfig := config
|
||||
resetMeowBadNick()
|
||||
defer func() {
|
||||
config = savedConfig
|
||||
resetMeowBadNick()
|
||||
}()
|
||||
|
||||
var mu sync.Mutex
|
||||
var hooks []notifyPayload
|
||||
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var p notifyPayload
|
||||
_ = json.NewDecoder(r.Body).Decode(&p)
|
||||
mu.Lock()
|
||||
hooks = append(hooks, p)
|
||||
mu.Unlock()
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer webhook.Close()
|
||||
|
||||
var meows []map[string]string
|
||||
var meowQuery []string
|
||||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
var body map[string]string
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
mu.Lock()
|
||||
meows = append(meows, body)
|
||||
meowQuery = append(meowQuery, r.URL.RawQuery)
|
||||
mu.Unlock()
|
||||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||
})
|
||||
|
||||
config.NotifyURL = webhook.URL
|
||||
config.NotifyToken = "hook-token"
|
||||
config.NotifyGroup = "777163254"
|
||||
config.NotifyLiveOn = true
|
||||
config.MeowEnabled = true
|
||||
config.MeowNickname = "小小怪"
|
||||
config.MeowMsgType = "text"
|
||||
|
||||
counts := func() (int, int) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return len(hooks), len(meows)
|
||||
}
|
||||
waitFor := func(tag string, wantHook, wantMeow int) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
h, m := counts()
|
||||
if h >= wantHook && m >= wantMeow {
|
||||
return
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
h, m := counts()
|
||||
t.Fatalf("%s:等超时了,webhook %d 条(期望 %d)、MeoW %d 条(期望 %d)", tag, h, wantHook, m, wantMeow)
|
||||
}
|
||||
|
||||
// 两个渠道都开:应该各收到一条
|
||||
config.NotifyEnabled = true
|
||||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "",
|
||||
"Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1")
|
||||
waitFor("两个渠道都开", 1, 1)
|
||||
|
||||
mu.Lock()
|
||||
h := hooks[0]
|
||||
m := meows[0]
|
||||
q := meowQuery[0]
|
||||
mu.Unlock()
|
||||
if h.Event != notifyEventLiveOn || h.Group != "777163254" || h.UID != 280178 {
|
||||
t.Errorf("webhook 收到的内容不对:%+v", h)
|
||||
}
|
||||
if m["msg"] != "Xsphere 开播了\n今天画点啥\nhttps://live.acfun.cn/1" {
|
||||
t.Errorf("MeoW 正文不对:%q", m["msg"])
|
||||
}
|
||||
if m["title"] != "开播提醒" {
|
||||
t.Errorf("MeoW 标题不对:%q", m["title"])
|
||||
}
|
||||
if m["url"] != "https://live.acfun.cn/1" {
|
||||
t.Errorf("MeoW 跳转链接不对:%q", m["url"])
|
||||
}
|
||||
if !strings.Contains(q, "msgType=text") {
|
||||
t.Errorf("MeoW 查询参数不对:%q", q)
|
||||
}
|
||||
|
||||
// 关掉 QQ 渠道,只剩 MeoW
|
||||
config.NotifyEnabled = false
|
||||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "再来一条")
|
||||
waitFor("只开 MeoW", 1, 2)
|
||||
|
||||
// 关掉 MeoW,只剩 QQ
|
||||
config.NotifyEnabled = true
|
||||
config.MeowEnabled = false
|
||||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "第三条")
|
||||
waitFor("只开 QQ", 2, 2)
|
||||
|
||||
// 两个都关,一条都不该发
|
||||
config.NotifyEnabled = false
|
||||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "不该发出")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if h, m := counts(); h != 2 || m != 2 {
|
||||
t.Errorf("两个渠道都关掉后不该再推送,实际 webhook %d 条、MeoW %d 条", h, m)
|
||||
}
|
||||
|
||||
// 事件没勾选时也不该发(渠道都开着)
|
||||
config.NotifyEnabled = true
|
||||
config.MeowEnabled = true
|
||||
config.NotifyLiveOn = false
|
||||
sendNotify(notifyEventLiveOn, 280178, "Xsphere", "今天画点啥", "https://live.acfun.cn/1", "", "事件没勾")
|
||||
time.Sleep(300 * time.Millisecond)
|
||||
if h, m := counts(); h != 2 || m != 2 {
|
||||
t.Errorf("事件没勾选时不该推送,实际 webhook %d 条、MeoW %d 条", h, m)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyEventTitle(t *testing.T) {
|
||||
for event, want := range map[string]string{
|
||||
notifyEventLiveOn: "开播提醒",
|
||||
notifyEventLiveOff: "下播提醒",
|
||||
notifyEventRecordStart: "开始录制",
|
||||
notifyEventMergeDone: "合并完成",
|
||||
} {
|
||||
if got := notifyEventTitle(event); got != want {
|
||||
t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 两个渠道的启用条件必须互相独立,并且都受各自开关控制
|
||||
func TestNotifySettingChannelReady(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
n notifySetting
|
||||
wantQQ, wantM bool
|
||||
}{
|
||||
{"都没开", notifySetting{}, false, false},
|
||||
{"QQ 开了但没填地址", notifySetting{qqEnabled: true}, false, false},
|
||||
{"QQ 开了且填了地址", notifySetting{qqEnabled: true, url: "http://x/y"}, true, false},
|
||||
{"QQ 填了地址但没开", notifySetting{url: "http://x/y"}, false, false},
|
||||
{"MeoW 开了但没昵称", notifySetting{meowEnabled: true}, false, false},
|
||||
{"MeoW 开了且有昵称", notifySetting{meowEnabled: true, meowNick: "JohnDoe"}, false, true},
|
||||
{"MeoW 昵称非法", notifySetting{meowEnabled: true, meowNick: "bad/nick"}, false, false},
|
||||
{"两个都开了", notifySetting{qqEnabled: true, url: "http://x/y", meowEnabled: true, meowNick: "JohnDoe"}, true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := c.n.qqReady(); got != c.wantQQ {
|
||||
t.Errorf("%s:qqReady = %v,期望 %v", c.name, got, c.wantQQ)
|
||||
}
|
||||
if got := c.n.meowReady(); got != c.wantM {
|
||||
t.Errorf("%s:meowReady = %v,期望 %v", c.name, got, c.wantM)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 老设置文件里没有 notifyEnabled,升级后不能把已经配好的推送静默关掉
|
||||
func TestNotifyEnabledBackwardCompat(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
savedConfig := config
|
||||
savedLocation := configFileLocation
|
||||
oldDir := configDir
|
||||
if configDir == nil {
|
||||
configDir = new(string)
|
||||
}
|
||||
defer func() {
|
||||
config = savedConfig
|
||||
configFileLocation = savedLocation
|
||||
configDir = oldDir
|
||||
}()
|
||||
|
||||
*configDir = dir
|
||||
configFileLocation = filepath.Join(dir, configFile)
|
||||
|
||||
write := func(content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(configFileLocation, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// 老配置:完全没提 notifyEnabled,但已经配好了地址
|
||||
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyLiveOn":true}`)
|
||||
loadConfig()
|
||||
if !config.NotifyEnabled {
|
||||
t.Errorf("老配置里没有 notifyEnabled 时应该默认开启,实际关闭了")
|
||||
}
|
||||
if config.NotifyURL == "" || !config.NotifyLiveOn {
|
||||
t.Errorf("其它字段被破坏:url=%q liveOn=%v", config.NotifyURL, config.NotifyLiveOn)
|
||||
}
|
||||
|
||||
// 显式写了 true
|
||||
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":true}`)
|
||||
loadConfig()
|
||||
if !config.NotifyEnabled {
|
||||
t.Errorf("显式写 true 时应该是开启")
|
||||
}
|
||||
|
||||
// 显式写了 false,必须真的关掉
|
||||
write(`{"notifyUrl":"http://127.0.0.1:8080/acfunlive/notify","notifyEnabled":false}`)
|
||||
loadConfig()
|
||||
if config.NotifyEnabled {
|
||||
t.Errorf("显式写 false 时应该是关闭")
|
||||
}
|
||||
}
|
||||
|
||||
// 测试接口:没填地址或昵称时给 400,填了就往对应渠道发
|
||||
func TestNotifyTestHandler(t *testing.T) {
|
||||
savedConfig := config
|
||||
savedLocation := configFileLocation
|
||||
oldDir := configDir
|
||||
if configDir == nil {
|
||||
configDir = new(string)
|
||||
}
|
||||
defer func() {
|
||||
config = savedConfig
|
||||
configFileLocation = savedLocation
|
||||
configDir = oldDir
|
||||
}()
|
||||
*configDir = t.TempDir()
|
||||
configFileLocation = filepath.Join(*configDir, configFile)
|
||||
|
||||
var webhookHits int
|
||||
webhook := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
webhookHits++
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer webhook.Close()
|
||||
|
||||
var meowNick string
|
||||
withMeowServer(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
meowNick = strings.TrimPrefix(r.URL.Path, "/")
|
||||
_, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`))
|
||||
})
|
||||
|
||||
call := func(body string) (int, string) {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/notify/test", strings.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
notifyTestHandler(rec, req)
|
||||
return rec.Code, rec.Body.String()
|
||||
}
|
||||
|
||||
// 什么都没配
|
||||
config.NotifyURL, config.MeowNickname = "", ""
|
||||
if code, msg := call(`{"channel":"qq"}`); code != 400 || !strings.Contains(msg, "推送地址") {
|
||||
t.Errorf("没填地址应该 400,实际 %d %s", code, msg)
|
||||
}
|
||||
if code, msg := call(`{"channel":"meow"}`); code != 400 || !strings.Contains(msg, "昵称") {
|
||||
t.Errorf("没填昵称应该 400,实际 %d %s", code, msg)
|
||||
}
|
||||
if code, msg := call(`{"channel":"whatever"}`); code != 400 || !strings.Contains(msg, "未知的推送渠道") {
|
||||
t.Errorf("未知渠道应该 400,实际 %d %s", code, msg)
|
||||
}
|
||||
|
||||
// 用页面上的值直接测 webhook,不需要先保存
|
||||
code, msg := call(`{"channel":"qq","url":"` + webhook.URL + `","group":"777163254"}`)
|
||||
if code != 200 || !strings.Contains(msg, `"ok":true`) || !strings.Contains(msg, "HTTP 200") {
|
||||
t.Errorf("QQ 渠道测试应该成功,实际 %d %s", code, msg)
|
||||
}
|
||||
if webhookHits != 1 {
|
||||
t.Errorf("webhook 应该被调用 1 次,实际 %d 次", webhookHits)
|
||||
}
|
||||
|
||||
// MeoW 渠道
|
||||
code, msg = call(`{"channel":"meow","nickname":"小小怪","msgType":"html","htmlHeight":300}`)
|
||||
if code != 200 || !strings.Contains(msg, "status 200") {
|
||||
t.Errorf("MeoW 渠道测试应该成功,实际 %d %s", code, msg)
|
||||
}
|
||||
if meowNick != "小小怪" {
|
||||
t.Errorf("MeoW 收到的昵称是 %q,期望 小小怪", meowNick)
|
||||
}
|
||||
|
||||
// 昵称带斜杠要被拦下(否则会打到别的路径上去)
|
||||
if code, _ := call(`{"channel":"meow","nickname":"bad/nick"}`); code != 400 {
|
||||
t.Errorf("非法昵称应该 400,实际 %d", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user