package main import ( "encoding/json" "errors" "io" "net/http" "net/http/httptest" "os" "os/exec" "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不该被解析") for _, want := range []string{ "
Xsphere 开播了
", // 第一行加粗 "今天画点啥
", // 普通行 ``, // 链接可点 "<i>不该被解析</i>", // 内容要转义 } { if !strings.Contains(got, want) { t.Errorf("HTML 里缺少 %q,实际:%s", want, got) } } if strings.Contains(got, "") { 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", `502`, "不是合法 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: "合并完成", notifyEventMergeFail: "合并失败", } { if got := notifyEventTitle(event); got != want { t.Errorf("notifyEventTitle(%q) = %q,期望 %q", event, got, want) } } } // 合并失败要推送,而「正常结束 / 文件里没有弹幕 / 用户自己点的停止」都不许推: // 前两种本来就不算失败,第三种推「合并失败」会误导 func TestNotifyMergeFailureFanOut(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() countHooks := func() int { mu.Lock() defer mu.Unlock() return len(hooks) } waitHooks := func(want int) { t.Helper() deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) && countHooks() < want { time.Sleep(20 * time.Millisecond) } } config.NotifyEnabled = true config.NotifyURL = webhook.URL config.NotifyMergeFail = true config.NotifyMergeDone = false notifyMergeFailure(280178, "2026-09-25 01-00-00 甲_弹幕合并.mp4", errors.New("磁盘写满了")) waitHooks(1) mu.Lock() got := append([]notifyPayload{}, hooks...) mu.Unlock() if len(got) != 1 { t.Fatalf("合并失败应当推送一条,实际 %d 条", len(got)) } if got[0].Event != notifyEventMergeFail { t.Errorf("事件名应当是 %s,实际 %s", notifyEventMergeFail, got[0].Event) } if !strings.Contains(got[0].Text, "磁盘写满了") { t.Errorf("正文里应当带上失败原因,实际:%s", got[0].Text) } notifyMergeFailure(280178, "x.mp4", nil) notifyMergeFailure(280178, "x.mp4", errNoDanmu) notifyMergeFailure(280178, "x.mp4", errJobStopped) time.Sleep(300 * time.Millisecond) if n := countHooks(); n != 1 { t.Errorf("正常结束 / 没有弹幕 / 手动停止都不该推送,实际共 %d 条", n) } config.NotifyMergeFail = false notifyMergeFailure(280178, "x.mp4", errors.New("又失败了")) time.Sleep(300 * time.Millisecond) if n := countHooks(); n != 1 { t.Errorf("没勾「合并失败」时不该推送,实际共 %d 条", n) } } // 两个渠道的启用条件必须互相独立,并且都受各自开关控制 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) } } // 手动合并完成也要推送 —— 以前只有自动合并会推,所以在页面上手动合并时 MeoW 一直没动静 func TestManualMergeSendsMergeDoneNotify(t *testing.T) { setupMergeTest(t) ffmpeg := getFFmpeg() if ffmpeg == "" { t.Skip("没有 ffmpeg") } savedConfig := config defer func() { config = savedConfig }() 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 meowQuery []string withMeowServer(t, func(w http.ResponseWriter, r *http.Request) { mu.Lock() meowQuery = append(meowQuery, r.URL.RawQuery) mu.Unlock() _, _ = w.Write([]byte(`{"status":200,"message":"推送成功"}`)) }) // 只开合并完成这一个事件,两个渠道都开 config.NotifyURL = webhook.URL config.NotifyLiveOn = false config.NotifyMergeDone = true config.MeowEnabled = true config.MeowNickname = "小小怪" config.MeowMsgType = "text" config.UIDs = uidList{{UID: 999, Danmu: true, Record: true}} // 文件名里带主播名,手动合并靠它认人 streamers.Lock() streamers.crt = map[int]streamer{999: {UID: 999, Name: "测试主播"}} streamers.Unlock() name := "2026-09-25 01-00-00 测试主播 标题" video := filepath.Join(recordSaveDir(), name+".mp4") if out, err := exec.Command(ffmpeg, "-v", "error", "-y", "-f", "lavfi", "-i", "color=c=black:size=320x180:rate=15", "-t", "3", "-c:v", "libx264", "-preset", "ultrafast", "-pix_fmt", "yuv420p", video).CombinedOutput(); err != nil { t.Fatalf("生成测试视频失败:%v\n%s", err, out) } lines := []string{`{"playResX":320,"playResY":180,"fontSize":16,"startTime":0}`, `{"t":0.5,"uid":101,"name":"甲","content":"手动合并推送测试"}`, `{"t":1.5,"uid":102,"name":"乙","content":"第二条"}`} writeTestFile(t, filepath.Join(recordSaveDir(), name+".jsonl"), strings.Join(lines, "\n")+"\n") plan, err := buildMergePlan(mergeRequest{Videos: []string{name + ".mp4"}, Danmu: name + ".jsonl"}) if err != nil { t.Fatalf("检查失败:%v", err) } if err := runManualMerge(plan); err != nil { t.Fatalf("手动合并失败:%v", err) } deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { mu.Lock() nh, nm := len(hooks), len(meowQuery) mu.Unlock() if nh > 0 && nm > 0 { break } time.Sleep(30 * time.Millisecond) } mu.Lock() defer mu.Unlock() if len(hooks) != 1 { t.Fatalf("QQ 机器人应收到 1 条推送,实际 %d 条", len(hooks)) } p := hooks[0] if p.Event != notifyEventMergeDone { t.Errorf("事件名应为 %s,实际 %s", notifyEventMergeDone, p.Event) } if p.UID != 999 || p.Name != "测试主播" { t.Errorf("应从文件名认出主播 999/测试主播,实际 %d/%q", p.UID, p.Name) } if p.File != plan.out { t.Errorf("推送里的文件应为成片 %q,实际 %q", plan.out, p.File) } if !strings.Contains(p.Text, "手动合并") || !strings.Contains(p.Text, plan.out) { t.Errorf("推送文案不对:%q", p.Text) } if len(meowQuery) != 1 { t.Fatalf("MeoW 应收到 1 条推送,实际 %d 条", len(meowQuery)) } if !strings.Contains(meowQuery[0], "msgType=text") { t.Errorf("MeoW 的 msgType 应走 query 参数,实际 %q", meowQuery[0]) } }