diff --git a/README.md b/README.md index 607218a..37ff8ea 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "webExternal": false, // 是否允许外网访问 web UI,false 时只监听本机 127.0.0.1 "authToken": "…", // web UI 管理接口的访问口令,首次启动自动生成 + "notifyEnabled": true, // QQ 机器人(webhook)推送的渠道开关,关掉后即使填了地址也不推送 "notifyUrl": "", // 开播/下播等事件推送到该地址(HTTP POST JSON),留空不推送 "notifyToken": "", // 推送时附带的 Bearer 口令,留空不带 "notifyGroup": "", // 推送时带上的群号,机器人可以直接用它决定发到哪个群 @@ -94,6 +95,12 @@ GOOS=windows GOARCH=amd64 go build -o acfunlive.exe "notifyRecordStart": false, // 开始录制直播视频时推送 "notifyMergeDone": false, // 自动合并完成时推送 + "meowEnabled": false, // MeoW 推送的渠道开关 + "meowNickname": "", // MeoW 昵称,推送地址里的路径参数 + "meowMsgType": "text", // MeoW 消息类型:text(纯文本)或 html + "meowHtmlHeight": 200, // MeoW 在 msgType 为 html 时的显示高度(像素) + "meowImgUrl": "", // MeoW 通知图标 URL,建议 216×216 的 PNG,留空用默认图标 + "ffmpegPath": "", // ffmpeg 可执行文件(或它所在文件夹)的路径,留空则自动查找 "recordFormat": "mp4", // 录播文件的封装格式:mp4、flv、ts、mkv "recordSubdir": "record", // 录播原片和弹幕文件存放在保存目录下的子文件夹名 @@ -201,7 +208,18 @@ web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动 ### 通知推送 -填好`notifyUrl`并勾选需要的事件后,程序会把事件以 JSON POST 到该地址,可以用来接自己的机器人(NoneBot、Koishi 等)或任何 HTTP 服务。 +开播、下播、开始录制、合并完成这四个事件可以推到两个渠道,两个渠道各推各的、互不影响: + +| 渠道 | 接收方 | 需要的配置 | +| --- | --- | --- | +| QQ 机器人 | 自己的机器人(NoneBot、Koishi 等)或任何 HTTP 服务 | `notifyUrl`(+ 可选的`notifyToken`、`notifyGroup`) | +| MeoW | 手机上 MeoW App 的通知 | `meowNickname` | + +「推哪些事件」只有一套勾选(`notifyLiveOn`等四个),两个渠道共用;每个渠道另有自己的开关(`notifyEnabled`、`meowEnabled`)。关掉开关只是暂停推送,地址、昵称这些配置都会留着,随时可以再打开。**没勾选的事件、或两个渠道都没启用时,程序什么都不做。** + +#### 渠道一:QQ 机器人 + +填好`notifyUrl`并勾选需要的事件后,程序会把事件以 JSON POST 到该地址。 推送的请求体: @@ -219,12 +237,33 @@ web UI 的`手动合并`区用来自己挑视频和弹幕出片,适合手动 } ``` -`notifyToken`非空时会以`Authorization: Bearer <口令>`和`X-Auth-Token`两个头一起发出去。推送在后台进行且有 10 秒超时,失败只在日志里记警告,不会影响录制。同一场直播的开播通知只会推送一次。web UI 设置区的`发送测试通知`按钮可以用当前填写的地址直接测试(不需要先保存)。 +`notifyToken`非空时会以`Authorization: Bearer <口令>`和`X-Auth-Token`两个头一起发出去。推送在后台进行且有 10 秒超时,失败只在日志里记警告,不会影响录制。同一场直播的开播通知只会推送一次。 `notifyGroup`是随推送下发的群号,留空则不带这个字段。它只是原样透传给机器人,程序自己不做任何判断——好处是群号、口令这些都能在 web UI 里改,不用去动机器人那边的配置。 接 NoneBot 的例子见`doc/momoko-acfunlive-notify.py`:把这个文件复制到 NoneBot 项目的插件目录即可,收到后直接把`text`发到群里。插件会优先使用推送里的`group`,只有推送没带时才回退到环境变量`ACFUNLIVE_NOTIFY_GROUP`,所以群号在 acfunlive 页面上改完立即生效,不用重启机器人。 +#### 渠道二:MeoW + +[MeoW](https://www.chuckfang.com/MeoW/api_doc.html) 是个消息推送 App,手机上装好、注册自己的昵称后,把昵称填进`meowNickname`打开开关,就能直接收到通知,不用自己架机器人。 + +- 消息内容用事件正文(就是上面 webhook 里的`text`),标题按事件取「开播提醒 / 下播提醒 / 开始录制 / 合并完成」 +- 开播、开始录制这类事件会带上直播间地址,MeoW 里点通知就能跳回直播间;合并完成带的是本地成片路径,手机上打不开,所以不带跳转链接 +- `meowMsgType`为`html`时会渲染成 HTML(第一行加粗、链接可点),`meowHtmlHeight`控制显示高度(默认 200,范围 40–2000);为`text`时就是纯文本,也是默认值 +- `meowImgUrl`可以换掉通知图标,官方建议用 216×216 的 PNG + +**注意 MeoW 的频控和封禁规则**:非会员是 3 秒 1 条、每分钟 15 条、每小时 60 条,超了返回 429;更麻烦的是**一天内累计 3 次推给不存在的昵称,它会把你的 IP 封掉**。所以程序做了保护:一旦某个昵称被 MeoW 判定为「昵称未注册」,就不再向它推送,页面上会红字提示原因,改掉昵称(或重启程序)才恢复。请先把昵称填对再打开开关。 + +#### 测试连通 + +两个渠道各有一个`测试连通`按钮,就在各自的配置下面: + +- 用**页面上当前填的值**直接测,不用先保存;留空的项自动用已保存的值补齐 +- 测试不受渠道开关和事件勾选影响,关着开关也能测(本来就是为了在打开之前先确认配置对不对) +- 成功后会显示实际结果:QQ 渠道显示 webhook 返回的 HTTP 状态码和耗时,MeoW 渠道显示它返回的`status`和耗时;失败则显示具体原因,比如`MeoW 返回 status 404(昵称未注册)` + +webhook 返回 200 只代表**地址和口令没问题**,能不能真的收到还要看机器人那边有没有正常处理——这一点测试结果里也会写明。 + ### 文件管理 web UI 的`文件管理`区可以列出录播文件夹和成片文件夹里的文件(名称、大小、修改时间),并显示保存目录所在磁盘的剩余和总容量。 diff --git a/config.go b/config.go index eddc396..1c6997a 100644 --- a/config.go +++ b/config.go @@ -89,6 +89,7 @@ type configData struct { AuthToken string `json:"authToken"` // web UI 管理接口的访问口令,首次启动自动生成 + NotifyEnabled bool `json:"notifyEnabled"` // QQ 机器人(webhook)推送的渠道开关,关掉后即使填了地址也不推送 NotifyURL string `json:"notifyUrl"` // 事件推送到该地址(HTTP POST JSON),留空不推送 NotifyToken string `json:"notifyToken"` // 推送时附带的 Bearer 口令,留空不带 NotifyGroup string `json:"notifyGroup"` // 推送时带上的群号,机器人可以直接用它决定发到哪个群 @@ -97,6 +98,12 @@ type configData struct { NotifyRecordStart bool `json:"notifyRecordStart"` // 开始录制直播视频时推送 NotifyMergeDone bool `json:"notifyMergeDone"` // 自动合并完成时推送 + MeowEnabled bool `json:"meowEnabled"` // MeoW 推送的渠道开关 + MeowNickname string `json:"meowNickname"` // MeoW 昵称,推送地址里的路径参数 + MeowMsgType string `json:"meowMsgType"` // MeoW 消息类型:text(默认,纯文本)或 html + MeowHTMLHeight int `json:"meowHtmlHeight"` // MeoW 在 msgType 为 html 时的显示高度(像素),默认 200 + MeowImgURL string `json:"meowImgUrl"` // MeoW 通知图标 URL,建议 216x216 的 PNG,留空用 MeoW 默认图标 + FFmpegPath string `json:"ffmpegPath"` // ffmpeg 可执行文件(或所在文件夹)的路径,留空则自动查找 RecordFormat string `json:"recordFormat"` // 录播视频的封装格式:mp4(默认,与原版一致)、flv、ts、mkv @@ -191,10 +198,11 @@ func recordFileExt() string { // 默认设置 var config = configData{ - UIDs: uidList{}, - WebPort: 51880, - RecordSubdir: defaultRecordSubdir, - MergeSubdir: defaultMergeSubdir, + UIDs: uidList{}, + WebPort: 51880, + RecordSubdir: defaultRecordSubdir, + MergeSubdir: defaultMergeSubdir, + NotifyEnabled: true, } // Windows 下不能用作文件(夹)名的保留字 @@ -256,6 +264,10 @@ func loadConfig() { checkErr(err) if json.Valid(data) { + // 老设置文件里没有 notifyEnabled 这个字段,默认按「开启」处理, + // 否则升级后已经配好的推送会被静默关掉。写在 Unmarshal 前面, + // 文件里显式写了 false 时仍会被覆盖成 false + config.NotifyEnabled = true err = json.Unmarshal(data, &config) checkErr(err) } else { @@ -372,6 +384,16 @@ func checkConfig() { } // 录播格式写错不致命,回退到 mp4,免得整个程序起不来 config.RecordFormat = normalizeRecordFormat(config.RecordFormat) + // MeoW 这几项写错同样不致命:类型和高度回退到默认值,昵称不可用只警告 + config.MeowNickname = strings.TrimSpace(config.MeowNickname) + config.MeowMsgType = normalizeMeowMsgType(config.MeowMsgType) + config.MeowImgURL = strings.TrimSpace(config.MeowImgURL) + if config.MeowHTMLHeight <= 0 { + config.MeowHTMLHeight = defaultMeowHTMLHeight + } + if msg := checkMeowNickname(config.MeowNickname, true); msg != "" { + lPrintWarnf("%s里的meowNickname不可用,不会推送 MeoW:%s", configFile, msg) + } // ffmpeg 路径填错时不致命(可能只是还没打算录视频),清空后回退到自动查找 if msg := checkFFmpegPath(config.FFmpegPath); msg != "" { lPrintWarnf("%s里的ffmpegPath不可用,将改为自动查找 ffmpeg:%s", configFile, msg) diff --git a/doc/webapi.md b/doc/webapi.md index 131acd7..191f21a 100644 --- a/doc/webapi.md +++ b/doc/webapi.md @@ -93,3 +93,16 @@ web UI(`-webui`,端口看`webPort`)用的是另一套`/api/`接口,全 `POST /api/merge` 真正开始合并,请求体和上面一样,另外可以带`outName`指定输出文件名(不合法会被清理成安全的文件名,重名自动加后缀)。成功后立刻返回`{"outName":"…"}`,合并在后台跑。已经有一个手动合并任务在跑时返回 409。 合并状态通过`GET /api/status`查看:`mergeBusy`(是否在跑)、`mergeOutName`(当前成片名)、`mergeErr`(失败原因)、`mergeDoneAt`(上次完成时间)。合并期间成片会出现在`GET /api/files`里且`inUse`为 true,此时`POST /api/file/remove`会返回 409 拒绝删除。 + +`POST /api/notify/test` 测试某个推送渠道能不能连通。用传进来的值直接测,留空的项自动用已保存的配置补齐,所以不用先保存设置。测试不受渠道开关和事件勾选影响。 + +``` +// QQ 机器人(webhook)渠道,channel 不填就按这个 +{ "channel": "qq", "url": "http://127.0.0.1:8080/acfunlive/notify", "token": "可选", "group": "可选" } +// MeoW 渠道 +{ "channel": "meow", "nickname": "你的昵称", "msgType": "text", "imgUrl": "可选", "htmlHeight": 200 } +``` + +成功后返回`{"ok":true,"detail":"webhook 返回 HTTP 200,用时 0.31 秒。…"}`,`detail`就是页面上显示的那句结论。失败时返回 502 并在响应体里给中文原因(如`MeoW 返回 status 404(昵称未注册)`),参数不合法返回 400。 + +MeoW 的`status`和封禁规则会反映在`GET /api/status`的`meowBlocked`字段里:昵称没填、格式不对、或已被 MeoW 判过「未注册」时,这里会给出中文原因,非空就说明 MeoW 渠道当前推不出去。注意 MeoW 一天内累计 3 次推给不存在的昵称会封 IP,所以被判过不存在的昵称会被程序记住并停止重试,改昵称或重启程序才恢复。 diff --git a/meow.go b/meow.go new file mode 100644 index 0000000..15967c1 --- /dev/null +++ b/meow.go @@ -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("
" + esc + "
") + first = false + case isHTTPURL(line): + b.WriteString(``) + default: + b.WriteString("" + esc + "
") + } + } + return b.String() +} diff --git a/notify.go b/notify.go index ee93282..38e235c 100644 --- a/notify.go +++ b/notify.go @@ -65,9 +65,18 @@ func notifiedLiveOnce(liveID string) bool { // 推送设置的一份快照,避免在推送过程中读到正在被修改的配置 type notifySetting struct { - url string - token string - group string + // QQ 机器人(webhook)渠道 + qqEnabled bool + url string + token string + group string + // MeoW 渠道 + meowEnabled bool + meowNick string + meowMsgType string + meowHTML int + meowImg string + // 推哪些事件,两个渠道共用 liveOn bool liveOff bool recordStart bool @@ -78,9 +87,15 @@ func currentNotifySetting() notifySetting { streamers.RLock() defer streamers.RUnlock() return notifySetting{ + qqEnabled: config.NotifyEnabled, url: strings.TrimSpace(config.NotifyURL), token: strings.TrimSpace(config.NotifyToken), group: strings.TrimSpace(config.NotifyGroup), + meowEnabled: config.MeowEnabled, + meowNick: strings.TrimSpace(config.MeowNickname), + meowMsgType: normalizeMeowMsgType(config.MeowMsgType), + meowHTML: clampMeowHTMLHeight(config.MeowHTMLHeight), + meowImg: strings.TrimSpace(config.MeowImgURL), liveOn: config.NotifyLiveOn, liveOff: config.NotifyLiveOff, recordStart: config.NotifyRecordStart, @@ -103,11 +118,41 @@ func (n notifySetting) enabled(event string) bool { return false } -// 推送一个事件。没配置推送地址或没开启该事件时什么都不做, +// QQ 机器人渠道是否具备推送条件:渠道开关打开且填了地址 +func (n notifySetting) qqReady() bool { + return n.qqEnabled && n.url != "" +} + +// MeoW 渠道是否具备推送条件:渠道开关打开、昵称可用、且这个昵称没被 MeoW 判过不存在 +func (n notifySetting) meowBlockedReason() string { + return meowNickProblem(n.meowNick) +} + +func (n notifySetting) meowReady() bool { + return n.meowEnabled && n.meowBlockedReason() == "" +} + +// 页面上要显示的 MeoW 提示,没问题时返回空字符串。调用方需要持有 streamers 锁 +func meowStatusProblem() string { + if !config.MeowEnabled { + return "" + } + return meowNickProblem(config.MeowNickname) +} + +// 推送一个事件。没开启对应事件、或两个渠道都没启用时什么都不做。 // 推送在后台 goroutine 里进行,失败只打警告,不会影响录制 func sendNotify(event string, uid int, name, title, url, file, text string) { n := currentNotifySetting() - if n.url == "" || !n.enabled(event) { + if !n.enabled(event) { + return + } + qq, meow := n.qqReady(), n.meowReady() + if !qq && !meow { + // MeoW 开着却推不了时说清楚原因,不然用户会以为推送整个坏了 + if n.meowEnabled { + lPrintWarnf("MeoW 渠道已启用但推不出去(%s),本次%s通知没有推送", n.meowBlockedReason(), event) + } return } p := notifyPayload{ @@ -121,26 +166,50 @@ func sendNotify(event string, uid int, name, title, url, file, text string) { Text: text, Time: getLogTime(), } - // 推送地址可能在推送过程中被改掉,这里把地址和口令带进闭包,保证用的是同一次配置 - addr, token := n.url, n.token - safeGo("推送"+event+"通知", func() { - if err := postNotify(addr, token, p); err != nil { - lPrintWarnf("推送%s通知失败:%v", event, err) - return + + // 推送地址和口令可能在推送过程中被改掉,这里带进闭包,保证用的是同一次配置 + if qq { + addr, token := n.url, n.token + safeGo("推送"+event+"通知到QQ机器人", func() { + if _, err := postNotify(addr, token, p); err != nil { + lPrintWarnf("推送%s通知到 QQ 机器人失败:%v", event, err) + return + } + lPrintf("已推送%s通知到 QQ 机器人:%s", event, p.Text) + }) + } + + if meow { + nick, img, msgType, msgHeight := n.meowNick, n.meowImg, n.meowMsgType, n.meowHTML + // 合并完成时带的是本地成片路径,手机上打不开,所以只认 http/https 的直播间地址 + jump := p.URL + if !isHTTPURL(jump) { + jump = "" } - lPrintf("已推送%s通知:%s", event, p.Text) - }) + eventTitle := notifyEventTitle(event) + content := p.Text + if msgType == meowMsgTypeHTML { + content = meowHTMLText(p.Text) + } + safeGo("推送"+event+"通知到MeoW", func() { + if err := pushMeow(nick, eventTitle, content, jump, img, msgType, msgHeight); err != nil { + lPrintWarnf("推送%s通知到 MeoW 失败:%v", event, err) + return + } + lPrintf("已推送%s通知到 MeoW(%s):%s", event, nick, p.Text) + }) + } } -// 把事件 POST 到 webhook -func postNotify(addr, token string, p notifyPayload) error { +// 把事件 POST 到 webhook,同时返回 HTTP 状态码(失败时也会带上,方便测试接口显示细节) +func postNotify(addr, token string, p notifyPayload) (int, error) { data, err := json.Marshal(p) if err != nil { - return err + return 0, err } req, err := http.NewRequest(http.MethodPost, addr, bytes.NewReader(data)) if err != nil { - return err + return 0, err } req.Header.Set("Content-Type", "application/json; charset=utf-8") if token != "" { @@ -150,14 +219,14 @@ func postNotify(addr, token string, p notifyPayload) error { resp, err := notifyClient.Do(req) if err != nil { - return err + return 0, err } defer resp.Body.Close() body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body))) + return resp.StatusCode, fmt.Errorf("HTTP %d %s", resp.StatusCode, strings.TrimSpace(string(body))) } - return nil + return resp.StatusCode, nil } // 检查设置里的推送地址,返回错误信息,空字符串表示通过 diff --git a/notify_test.go b/notify_test.go new file mode 100644 index 0000000..ae79c9e --- /dev/null +++ b/notify_test.go @@ -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不该被解析") + 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: "合并完成", + } { + 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) + } +} diff --git a/webui.go b/webui.go index adb75ba..d3aeb6e 100644 --- a/webui.go +++ b/webui.go @@ -53,7 +53,10 @@ const webuiHTML = ` button:active,a.ghost:active{opacity:.8} button:disabled{opacity:.5} .hint{font-size:12px;color:var(--sub);margin-top:6px;line-height:1.6} + .hint.bad{color:#d63046} .hr{height:1px;background:var(--soft);margin:12px -14px} + .ch{display:flex;align-items:center;gap:8px;font-size:12px;font-weight:600;color:#333;margin:12px 0 8px} + .ch::after{content:"";flex:1;height:1px;background:var(--soft)} .row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 12px;padding:10px 0;border-top:1px solid var(--soft)} .row:first-child{border-top:none;padding-top:2px} .uid{font-size:12px;color:var(--sub);font-variant-numeric:tabular-nums} @@ -114,21 +117,44 @@ const webuiHTML = `