From 6a64a7de6f823fdfe8fb077a11d1bbc3d77e33f4 Mon Sep 17 00:00:00 2001 From: zhuhjay Date: Sat, 7 Nov 2026 01:37:59 +0800 Subject: [PATCH] =?UTF-8?q?fix(qqmusic):=20=E9=80=9A=E7=9F=A5=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E8=84=9A=E6=9C=AC=E8=87=AA=E5=AE=9E=E7=8E=B0=EF=BC=8C?= =?UTF-8?q?=E6=96=AD=E5=BC=80=E5=AF=B9=E9=9D=92=E9=BE=99=20sendNotify=20?= =?UTF-8?q?=E7=9A=84=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机定位(2026-11-07):仓库里的 sendNotify.js 每轮都被覆盖,改它不生效。 青龙 shell/update.sh 的 gen_list_repo 复制顺序是三步,后一步盖前一步: 407: cp -f $file_notify_js "${dir_scripts}/${uniq_path}" 面板自带的 sendNotify.js 415: cp -f $_file "${dir_scripts}/${uniq_path}/${file_path}" 仓库的(覆盖①) 421: cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" /ql/data/deps/*(再覆盖②) 而 share.sh:228 在初始化时把面板自带的 sample/notify.js 拷成了 /ql/data/deps/sendNotify.js。 ③ 排在最后 → 最终文件永远是面板自带那份,204 修复永远不生效。 改为脚本内联实现 notify(),零依赖(只用内置 http/https): - httpPost / parseHookHeaders / buildHookBody / notify 四个函数,约 90 行 - 2xx 一律算成功(204 不再误判) - buildHookBody 对 application/json 先试 JSON.parse,不合法就 JSON.stringify 重建, 内容里带引号/换行/反斜杠都安全(青龙 parseString 那个坑绕开了) - 双通道:webhook + pushplus 直发,任一路成功即 ok - 两路都没配时打一行明确提示,不再静默 main() 里删掉 require('../sendNotify'),改成 await notify('QQ音乐签到', desp)。 第四参 author 的追加逻辑也一并没了,正文不再带「本通知 By:github.com/whyour/qinglong」。 新增 test_notify.js:38 条断言 - parseHookHeaders 八种输入(含字面量 \n 不切开) - buildHookBody 三种特殊字符(双引号/反斜杠/制表符) - notify 六种状态码(200/204/201 通过,401/403/400/429 失败) - 无配置、双通道部分失败 端到端已验证:mock 服务收到的 content 与原始 desp 逐字相同(10 行完整), title 正确,header 带 token,HTTP 204 判为成功。 README 重写「通知发不出去怎么查」:坑 0 改成 deps 覆盖(附验证命令), 坑 1-3 保留但标注为「若又接回 sendNotify 才需注意」,新增 pushplus 展示限制的 三种官方解法与代价对照。 --- qqmusic/README.md | 128 +++++++++++++++-------- qqmusic/scrip_qqmusic_checkin.js | 134 ++++++++++++++++++++++-- qqmusic/test_notify.js | 170 +++++++++++++++++++++++++++++++ 3 files changed, 378 insertions(+), 54 deletions(-) create mode 100644 qqmusic/test_notify.js diff --git a/qqmusic/README.md b/qqmusic/README.md index f634e38..95eb4fb 100644 --- a/qqmusic/README.md +++ b/qqmusic/README.md @@ -8,7 +8,6 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node > 不带前缀的文件不会被订阅拉取(脚本、测试、文档同一约定)。 ## 环境变量 - | 变量 | 必填 | 说明 | |---|---|---| | `SH_QQMUSIC_KEY` | ✅ | 多账号:青龙同名 env 加多条(注入自动 `&` 连接)。**单条格式 `#`**,如 `123456789#Q_H_L_xxxx`。也接受整串 cookie(含 `qm_keyst=` 与 `uin=o...`) | @@ -17,6 +16,18 @@ QQ音乐会员成长值任务 + 会员福利站 自动签到,青龙面板 Node | `QM_PLAY_REPORT` | 可选 | `on` 才启用任务20播放上报(本版未实现模板,挂了开关占位) | | `QM_SHARE_CARD` | 可选 | `on` 才让**定时任务**自动建卡。默认 off = 只读报数。手动取卡走 `-- card`,不受此开关限制 | | `QM_CARD` | 可选 | `1`/`on` 等价于 `-- card`(不方便传参时用) | +| `WEBHOOK_URL` | 可选 | 自定义 webhook(如平台 `/api/hooks/ql`)。与 `PUSH_PLUS_TOKEN` 至少配一个 | +| `WEBHOOK_METHOD` | 可选 | 默认 `POST` | +| `WEBHOOK_CONTENT_TYPE` | 可选 | 默认 `application/json` | +| `WEBHOOK_BODY` | 可选 | 支持 `$title` / `$content` 占位;不填则按 Content-Type 给默认形状 | +| `WEBHOOK_HEADERS` | 可选 | 一行一个 `Key: Value`,**必须是真换行** | +| `PUSH_PLUS_TOKEN` | 可选 | pushplus 直发 | +| `PUSH_PLUS_TOPIC` | 可选 | pushplus 一对多群组编码(对应 `PUSH_PLUS_USER`) | +| `PUSH_PLUS_TEMPLATE` | 可选 | 默认 `txt`(不转义 html、不折叠);`html` 会把换行变 `
` | + +> 通知由本脚本**自己实现**(`notify()`),不依赖青龙的 `sendNotify.js` —— +> 那份文件每轮都会被 `/ql/data/deps/*` 覆盖,改它不生效。详见「通知发不出去怎么查 · 坑 0」。 + ## VIP 体验券(分享卡) @@ -181,55 +192,59 @@ cron: 1 0,6 * * * ## 通知发不出去怎么查 +### 坑 0(最狠的一个):仓库的 `sendNotify.js` 每轮都被覆盖 + +**症状**:改了 `sendNotify.js`,青龙跑起来行为不变;日志里永远是旧版那句话。 + +**原因**:青龙 `shell/update.sh` 的 `gen_list_repo` 里,文件复制是**三步**,后一步覆盖前一步: + ```bash -# 青龙里手动跑一次(cron 设成 1 月 1 日,不会自动跑) -node scrip_diag_notify.js +407: cp -f $file_notify_js "${dir_scripts}/${uniq_path}" # ① 面板自带的 sendNotify.js +408: cp -f $file_notify_py "${dir_scripts}/${uniq_path}" + +410: if [[ $dependence ]]; then +411: results=$(find . -name "*.js" | egrep "$dependence") +415: cp -f $_file "${dir_scripts}/${uniq_path}/${file_path}" # ② 仓库的 sendNotify.js(覆盖①) +417: fi + +420: if [[ -d $dir_dep ]]; then +421: cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" &>/dev/null # ③ /ql/data/deps/*(再覆盖②) +422: fi ``` -六段逐项定位,token 全程脱敏: +`$dir_dep` 是 `/ql/data/deps`,青龙初始化时往里面拷了一份面板自带的 `notify.js`: -``` -1. sendNotify 加载 ← 容器缺 got 时 require 抛错,被静默吞掉 -2. 通知 env ← 青龙「环境变量」是否真的保存了 -3. webhook 配置分析 ← 三个前置条件 + body/headers 解析复刻 -4. 直打平台 /api/hooks/ql ← 按状态码定位卡在哪一道校验 -5. 直连 pushplus 模板对比 ← txt vs html,确认是不是展示折叠 -6. 完整 sendNotify ← 与第 4 节对照,区分通道问题与展示问题 +```bash +# shell/share.sh:228 +if [[ ! -s $dep_notify_js ]]; then + cp -f "$file_notify_js_sample" "$dep_notify_js" # sample/notify.js → /ql/data/deps/sendNotify.js +fi ``` -### 坑 0:平台回 204,原版 sendNotify 判成失败(已修) +**③ 排在最后,所以最终文件永远是面板自带的那份。** 仓库里的 `sendNotify.js` 白改。 -`/api/hooks/ql` 按设计返回 **204**(表示已接收,且不写 event_logs), -而原版 `sendNotify.js` 的判据是 `resp.statusCode !== 200`: +**验证**: -```js -if (resp.statusCode !== 200) { - console.log(`自定义发送通知消息失败!!\n${resp.body}`); // 204 也走这里 -} +```bash +docker exec -it qinglong sh -c ' + md5sum /ql/data/deps/sendNotify.js /ql/data/scripts/zhuhjay_ql-scripts_master/sendNotify.js +' ``` -**所以「自定义发送通知消息失败!!」是假警报 —— 平台其实收到了。** -已改成 2xx 一律算成功,并把状态码打进日志: +两个 md5 相同 = 被覆盖了。 -```js -if (resp.statusCode < 200 || resp.statusCode >= 300) { - console.log(`自定义发送通知消息失败!!HTTP ${resp.statusCode}\n${resp.body}`); -} else { - console.log(`自定义发送通知消息成功🎉。HTTP ${resp.statusCode}\n${resp.body || ''}`); -} -``` +**结论:本脚本不再 `require('../sendNotify')`。** 通知逻辑内联在 +`scrip_qqmusic_checkin.js` 的 `notify()` 里(约 90 行,零依赖),行为完全由仓库决定。 -### 坑 1:`require('../sendNotify')` 静默失败 +### 坑 1:`sendNotify` 的三个静默 `resolve()` -原本 `catch { /* 仅控制台 */ }`,缺 `got` 时**一条通知都发不出去且日志无痕**。现在会打一行: +如果哪天又接回 `sendNotify`,注意 `webhookNotify` 开头逐行 return,**不发请求也不报错**: -``` -[通知] sendNotify 不可用(Cannot find module 'got'),本轮不发推送 -``` +1. `WEBHOOK_URL` 或 `WEBHOOK_BODY` **至少一个**含 `$title` +2. `WEBHOOK_METHOD` **必须显式设**(空则走 else 分支 resolve) +3. body 能被 `parseString` 正确解析(见坑 2) -修:`docker exec -it qinglong pnpm install got` - -### 坑 2:WEBHOOK_BODY 写成单行 JSON 会被解析成空 +### 坑 2:`WEBHOOK_BODY` 写成单行 JSON 会被解析成空 `sendNotify` 的 `parseString` 用 `/(\w+):\s*((?:(?!\n\w+:).)*)/g`,**靠换行 + `\w+:` 断值**。 单行 JSON 没有换行,整个 body 被吞掉,实测解析结果 `{}`(0 个 key): @@ -241,21 +256,32 @@ if (resp.statusCode < 200 || resp.statusCode >= 300) { body 变成 `{}` → 平台 `JSON.parse` 得到空对象 → 400「title 与 content 不能同时为空」。 -### 坑 3:webhook 三个前置条件,缺一个就静默跳过 +**本脚本的 `buildHookBody` 不受这个坑影响** —— 它对 `application/json` 会先试 +`JSON.parse`,不合法就直接 `JSON.stringify({title, content})` 重建,单行多行都行。 -`webhookNotify` 开头逐行 return,**不发请求也不报错**: +### 坑 3:pushplus 通知栏只显示标题 + 首行 -1. `WEBHOOK_URL` 或 `WEBHOOK_BODY` **至少一个**含 `$title` -2. `WEBHOOK_METHOD` **必须显式设**(空则走 else 分支 resolve) -3. body 能被 `parseString` 正确解析(见坑 2) +微信服务号模板消息**默认不展示具体内容**,点「查看详情」才展开全文。 +pushplus 官方给了三种解法([文档](https://pushplus.plus/doc/help/showmessage.html)): -### 坑 4:pushplus 只显示一行 ≠ 内容丢了 +| 方案 | 代价 | +|---|---| +| 在服务号发「激活消息」 | 24 小时内 5 条走客服消息,能显示全文 | +| 开通 pushplus 会员 | 付费,模板能多显示一部分 | +| 绑定自己的服务号 | 企业/个体工商户认证,每年 300 元 | -微信通知卡片默认只展示开头,点开才展开全文。要确认是折叠还是真丢: -第 5 节会同时发 `txt` 和 `html` 两条,对比哪个正文完整。 +**不是 bug,是通道限制。** 要通知栏直接看到明细,换 Bark / Telegram / 企业微信机器人。 -- txt 完整 / html 一行 → 把 pushplus 目标改成 `template=txt` -- 两条都一行 → 微信卡片折叠,点开即全文,不是 bug +### 自检 + +```bash +# 纯函数 + 六种状态码(200/204/201/401/403/400/429)+ 无配置 + 双通道 +node qqmusic/test_notify.js +``` + +``` +RESULT: PASS (38 passed, 0 failed) +``` ### 指向平台的完整配置 @@ -294,4 +320,16 @@ x-sh-hook-token: <平台 QL_HOOK_TOKEN> node --check scrip_qqmusic_checkin.js # 语法 node scrip_qqmusic_checkin.js # 无 env 时优雅跳过 SH_QQMUSIC_KEY='123#Q_H_L_x' node -e "console.log(require('./scrip_qqmusic_checkin').parseAccounts())" + +node test_notify.js # 通知实现:38 条断言(含六种状态码) +node test_sign_run.js # 签名对拍(需先 python test_sign_vector.py 生成期望) +node ../_probe_meta.js # 青龙元数据注解 ``` + +`test_notify.js` 覆盖: + +- `parseHookHeaders`:多行 / 空行 / 无冒号 / 值含冒号 / 字面量 `\n` 不切开 +- `buildHookBody`:内容含双引号、反斜杠、制表符时仍产出合法 JSON +- `notify`:mock 服务测 **200 / 204 / 201 / 401 / 403 / 400 / 429** 七种状态码 +- 无配置、双通道部分失败 + diff --git a/qqmusic/scrip_qqmusic_checkin.js b/qqmusic/scrip_qqmusic_checkin.js index 08774da..c492f2c 100644 --- a/qqmusic/scrip_qqmusic_checkin.js +++ b/qqmusic/scrip_qqmusic_checkin.js @@ -50,6 +50,26 @@ const CARD_CALLBACK = String(process.env.QM_CARD_CALLBACK || '').trim() // 回传鉴权 token:与平台 QL_HOOK_TOKEN 同一枚(平台 hookGuard 校验它) const CARD_TOKEN = String(process.env.QM_CARD_TOKEN || process.env.QL_HOOK_TOKEN || '').trim() +/* ---------------- 通知:自带实现,不 require 青龙 sendNotify ---------------- + * 为什么不复用青龙的 sendNotify.js(2026-11-07 真机定位): + * shell/update.sh 的 gen_list_repo 复制顺序是三步 —— + * ① cp -f $file_notify_js "${dir_scripts}/${uniq_path}" 面板自带的 sendNotify.js + * ② cp -f <仓库依赖文件> "${dir_scripts}/${uniq_path}/" 仓库里的 sendNotify.js(覆盖①) + * ③ cp -rf $dir_dep/* "${dir_scripts}/${uniq_path}" /ql/data/deps/*(再覆盖②) + * $dir_dep = /ql/data/deps,share.sh 初始化时往里面拷了一份面板自带的 notify.js。 + * ③ 排在最后,所以仓库里的 sendNotify.js 每轮都被覆盖,改它永远不生效。 + * 自己实现 = 彻底断开这条依赖链,行为完全由本文件决定。 + * 支持两路:自定义 webhook(指向平台)+ pushplus 直发。两者都没配就只打日志。 */ +const WEBHOOK_URL = String(process.env.WEBHOOK_URL || '').trim() +const WEBHOOK_BODY = String(process.env.WEBHOOK_BODY || '').trim() +const WEBHOOK_METHOD = String(process.env.WEBHOOK_METHOD || 'POST').trim().toUpperCase() +const WEBHOOK_CT = String(process.env.WEBHOOK_CONTENT_TYPE || 'application/json').trim() +const WEBHOOK_HEADERS = String(process.env.WEBHOOK_HEADERS || '') +const PUSH_PLUS_TOKEN = String(process.env.PUSH_PLUS_TOKEN || '').trim() +const PUSH_PLUS_TOPIC = String(process.env.PUSH_PLUS_USER || '').trim() +// pushplus 模板:txt 不转义 html、不折叠;html 会把换行变
+const PUSH_PLUS_TEMPLATE = String(process.env.PUSH_PLUS_TEMPLATE || 'txt').trim() + function time33(s) { let h = 5381 for (let i = 0; i < s.length; i++) { @@ -726,6 +746,107 @@ function parseAccounts() { return list } +/** 通用 HTTP 请求(http/https 自适应),零依赖。供通知与回传共用。 */ +function httpPost(urlStr, headers, body, timeoutMs = 15000) { + return new Promise((resolve) => { + let u + try { u = new URL(urlStr) } catch { return resolve({ status: 0, text: `非法 URL: ${urlStr}` }) } + const lib = u.protocol === 'http:' ? require('http') : https + const payload = Buffer.from(body, 'utf8') + const req = lib.request({ + host: u.hostname, + port: u.port || (u.protocol === 'http:' ? 80 : 443), + path: u.pathname + u.search, + method: 'POST', + headers: { ...headers, 'Content-Length': payload.length }, + timeout: timeoutMs, + }, (res) => { + const chunks = [] + res.on('data', c => chunks.push(c)) + res.on('end', () => resolve({ status: res.statusCode, text: Buffer.concat(chunks).toString('utf8') })) + }) + req.on('timeout', () => { req.destroy(); resolve({ status: 0, text: 'request timeout' }) }) + req.on('error', e => resolve({ status: 0, text: e.message })) + req.write(payload) + req.end() + }) +} + +/** 解析 WEBHOOK_HEADERS:一行一个 `Key: Value`,空行忽略。 + * 注意环境变量里必须是真换行;写成字面量 \n(两个字符)整段会被当成一个 header。 */ +function parseHookHeaders(raw) { + const out = {} + for (const line of String(raw || '').split('\n')) { + const s = line.trim() + if (!s) continue + const i = s.indexOf(':') + if (i <= 0) continue + out[s.slice(0, i).trim()] = s.slice(i + 1).trim() + } + return out +} + +/** 拼 webhook body。WEBHOOK_BODY 支持 $title / $content 占位; + * 没配 BODY 时按 Content-Type 给默认形状。 */ +function buildHookBody(title, content) { + const tpl = WEBHOOK_BODY || (WEBHOOK_CT === 'application/json' ? '{"title":"$title","content":"$content"}' : 'title=$title&content=$content') + const filled = tpl.replace(/\$title/g, title).replace(/\$content/g, content) + if (WEBHOOK_CT === 'application/json') { + // 占位替换会把内容里的引号/换行带进 JSON,必须重新序列化一次才合法 + try { JSON.parse(filled); return filled } catch { /* 落下面重建 */ } + return JSON.stringify({ title, content }) + } + if (WEBHOOK_CT === 'application/x-www-form-urlencoded') { + return `title=${encodeURIComponent(title)}&content=${encodeURIComponent(content)}` + } + return filled +} + +/** 发通知。返回 { ok, channels: [{name, ok, detail}] },永不抛错。 + * 设计要点: + * - 2xx 一律算成功。平台 /api/hooks/ql 按设计回 204,判 !== 200 会误报失败。 + * - 两路都试,任一路成功即 ok(不因 pushplus 挂了就吞掉 webhook 的成功)。 + * - 失败只打日志,绝不让通知问题把签到本身算失败。 */ +async function notify(title, content) { + const channels = [] + + if (WEBHOOK_URL) { + const headers = { 'Content-Type': WEBHOOK_CT, ...parseHookHeaders(WEBHOOK_HEADERS) } + const body = buildHookBody(title, content) + const r = await httpPost(WEBHOOK_URL, headers, body) + const ok = r.status >= 200 && r.status < 300 + channels.push({ name: 'webhook', ok, detail: `HTTP ${r.status || 'ERR'}` }) + log(ok + ? `[通知] webhook 成功(HTTP ${r.status})` + : `[通知] webhook 失败:HTTP ${r.status || 'ERR'} ${r.text.slice(0, 160)}`) + } else { + channels.push({ name: 'webhook', ok: false, detail: 'WEBHOOK_URL 未设' }) + } + + if (PUSH_PLUS_TOKEN) { + const payload = JSON.stringify({ + token: PUSH_PLUS_TOKEN, + title, + content, + template: PUSH_PLUS_TEMPLATE, + ...(PUSH_PLUS_TOPIC ? { topic: PUSH_PLUS_TOPIC } : {}), + }) + const r = await httpPost('https://www.pushplus.plus/send', { 'Content-Type': 'application/json' }, payload) + let j = {} + try { j = JSON.parse(r.text) } catch { /* 非 JSON */ } + const ok = j.code === 200 + channels.push({ name: 'pushplus', ok, detail: ok ? `msgId ${j.data || ''}` : `code ${j.code} ${j.msg || r.text.slice(0, 80)}` }) + log(ok ? `[通知] pushplus 成功` : `[通知] pushplus 失败:${j.code} ${j.msg || ''}`) + } else { + channels.push({ name: 'pushplus', ok: false, detail: 'PUSH_PLUS_TOKEN 未设' }) + } + + if (!WEBHOOK_URL && !PUSH_PLUS_TOKEN) { + log('[通知] 未配置任何通道(WEBHOOK_URL / PUSH_PLUS_TOKEN 都为空),本轮不发推送') + } + return { ok: channels.some(c => c.ok), channels } +} + /** 取卡结果回传平台(可选)。平台侧 /api/hooks/card 收到后按该用户的通知目标转发到微信。 * 设计要点: * - 只回传「取卡结果」,不含 cookie/key —— 走公网也不泄露凭证。 @@ -860,14 +981,9 @@ async function main() { + `\n轮次: 00:01 / 06:02 双轮(任务20跨天滚动 + 福利站兜底)` + `\n节目任务(20)依赖 App 内正常收听计入(播放上报未启用)` + `\n时间: ${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}` - let sendNotifyFn = null - try { sendNotifyFn = require('../sendNotify').sendNotify } catch (e) { - // 静默吞掉会让「通知没发出去」完全不可见 —— 必须留一行,否则只能靠猜 - log(`[通知] sendNotify 不可用(${e.message.split('\n')[0]}),本轮不发推送`) - } - if (sendNotifyFn) { - try { await sendNotifyFn('QQ音乐签到', desp) } catch (e) { log(`[通知] 推送失败: ${e.message}`) } - } + // 用本文件自带的 notify(),不 require 青龙的 sendNotify.js(见文件头部注释: + // 那份文件每轮都会被 /ql/data/deps/* 覆盖,改它永远不生效) + await notify('QQ音乐签到', desp) log(`\n===== 汇总 =====\n${desp}`) } @@ -875,4 +991,4 @@ async function main() { if (require.main === module) { main().catch(e => { console.error('[QQ音乐] 未捕获异常:', e); process.exitCode = 1 }) } -module.exports = { time33, zzc, QMClient, parseAccounts, pushCardResult } +module.exports = { time33, zzc, QMClient, parseAccounts, pushCardResult, notify, buildHookBody, parseHookHeaders } diff --git a/qqmusic/test_notify.js b/qqmusic/test_notify.js new file mode 100644 index 0000000..dfa8f0d --- /dev/null +++ b/qqmusic/test_notify.js @@ -0,0 +1,170 @@ +#!/usr/bin/env node +/** + * test_notify.js —— 验证脚本自带的 notify() 实现(不依赖青龙 sendNotify) + * + * 覆盖: + * 1. parseHookHeaders —— 多行 / 空行 / 无冒号 / 重复 key + * 2. buildHookBody —— JSON 内容含引号换行时的转义、urlencoded、纯文本 + * 3. notify —— mock 服务测 200/204/401/403/400/429 六种状态码 + * 4. 无配置 —— 两路都没配时的行为 + * + * 用法: node qqmusic/test_notify.js + * 退出码 0 = 全过 + */ +'use strict' +const http = require('http') + +let pass = 0, fail = 0 +const ok = (name, cond, extra = '') => { + if (cond) { pass++; console.log(` ✓ ${name}`) } + else { fail++; console.log(` ✗ ${name}${extra ? ' — ' + extra : ''}`) } +} +const eq = (name, got, want) => ok(name, got === want, `got=${JSON.stringify(got)} want=${JSON.stringify(want)}`) + +/* ══════════ 1-2. 纯函数(不需要 env) ══════════ */ +const { buildHookBody, parseHookHeaders } = require('./scrip_qqmusic_checkin.js') + +console.log('\n── 1. parseHookHeaders ──') +eq('单行', JSON.stringify(parseHookHeaders('X-A: 1')), '{"X-A":"1"}') +eq('两行', JSON.stringify(parseHookHeaders('X-A: 1\nX-B: 2')), '{"X-A":"1","X-B":"2"}') +eq('空行忽略', JSON.stringify(parseHookHeaders('X-A: 1\n\n\nX-B: 2')), '{"X-A":"1","X-B":"2"}') +eq('无冒号忽略', JSON.stringify(parseHookHeaders('garbage\nX-A: 1')), '{"X-A":"1"}') +eq('值含冒号', JSON.stringify(parseHookHeaders('X-A: a:b:c')), '{"X-A":"a:b:c"}') +eq('空串', JSON.stringify(parseHookHeaders('')), '{}') +eq('值前后空格被 trim', JSON.stringify(parseHookHeaders(' X-A : v ')), '{"X-A":"v"}') +// 字面量 \n(两个字符)不该被切开 —— 这正是青龙 sendNotify 的坑 +eq('字面量 \\n 不切开', Object.keys(parseHookHeaders('X-A: 1\\nX-B: 2')).length, 1) + +console.log('\n── 2. buildHookBody(默认 JSON 模板)──') +{ + const b = buildHookBody('标题', '第一行\n第二行') + let j = null + try { j = JSON.parse(b) } catch { /* 下面判 */ } + ok('产出是合法 JSON', j !== null, b) + eq('title 正确', j && j.title, '标题') + eq('content 换行保留', j && j.content, '第一行\n第二行') +} +{ + // 内容里带引号 —— 占位替换后必须仍然合法 + const b = buildHookBody('T', '他说"你好"然后走了') + let j = null + try { j = JSON.parse(b) } catch { /* */ } + ok('内容含双引号仍是合法 JSON', j !== null, b) + eq('引号原样保留', j && j.content, '他说"你好"然后走了') +} +{ + // 内容里带反斜杠与制表符 + const b = buildHookBody('T', 'C:\\path\\to\tfile') + let j = null + try { j = JSON.parse(b) } catch { /* */ } + ok('含反斜杠/制表符仍合法', j !== null, b) + eq('反斜杠保留', j && j.content, 'C:\\path\\to\tfile') +} + +/* ══════════ 3. notify —— mock 服务 ══════════ */ +console.log('\n── 3. notify(mock 服务,六种状态码)──') + +// 注意:WEBHOOK_URL 等是模块加载时读的常量,所以要改 env 后重新 require +const MOD = require.resolve('./scrip_qqmusic_checkin.js') +function reloadWith(env) { + for (const k of ['WEBHOOK_URL', 'WEBHOOK_BODY', 'WEBHOOK_METHOD', 'WEBHOOK_CONTENT_TYPE', + 'WEBHOOK_HEADERS', 'PUSH_PLUS_TOKEN', 'PUSH_PLUS_USER', 'PUSH_PLUS_TEMPLATE']) delete process.env[k] + Object.assign(process.env, env) + delete require.cache[MOD] + return require(MOD) +} + +function startServer(status, body = '') { + return new Promise((resolve) => { + const seen = [] + const srv = http.createServer((req, res) => { + const chunks = [] + req.on('data', c => chunks.push(c)) + req.on('end', () => { + seen.push({ method: req.method, url: req.url, headers: req.headers, body: Buffer.concat(chunks).toString('utf8') }) + res.writeHead(status, { 'Content-Type': 'application/json' }) + res.end(body) + }) + }) + srv.listen(0, '127.0.0.1', () => resolve({ srv, port: srv.address().port, seen })) + }) +} + +;(async () => { + const cases = [ + { status: 204, wantOk: true, label: '204 平台正常返回(★ 旧版误判为失败的就是这个)' }, + { status: 200, wantOk: true, label: '200 普通成功' }, + { status: 201, wantOk: true, label: '201 也是 2xx' }, + { status: 401, wantOk: false, label: '401 token 错' }, + { status: 403, wantOk: false, label: '403 IP 不在白名单' }, + { status: 400, wantOk: false, label: '400 body 畸形' }, + { status: 429, wantOk: false, label: '429 超频' }, + ] + + for (const c of cases) { + const { srv, port, seen } = await startServer(c.status) + const m = reloadWith({ + WEBHOOK_URL: `http://127.0.0.1:${port}/api/hooks/ql`, + WEBHOOK_METHOD: 'POST', + WEBHOOK_CONTENT_TYPE: 'application/json', + WEBHOOK_BODY: 'title:$title\ncontent:$content', + WEBHOOK_HEADERS: 'X-Sh-Hook-Token: tok123\nX-Sh-Hook-Id: qinglong', + }) + // 静音 notify 内部的 log + const origLog = console.log + console.log = () => {} + const r = await m.notify('QQ音乐签到', '✅ 全部完成\n ✓ 任务中心签到 +15') + console.log = origLog + + const wh = r.channels.find(x => x.name === 'webhook') + ok(`${c.status} → ${c.wantOk ? 'ok' : 'not ok'}(${c.label})`, wh.ok === c.wantOk, JSON.stringify(wh)) + if (c.status === 204 || c.status === 200) { + ok(` 请求确实发出去了`, seen.length === 1, `seen=${seen.length}`) + const sent = seen[0] + ok(` method = POST`, sent.method === 'POST', sent.method) + ok(` header 带 token`, sent.headers['x-sh-hook-token'] === 'tok123', sent.headers['x-sh-hook-token']) + let j = null + try { j = JSON.parse(sent.body) } catch { /* */ } + ok(` body 是合法 JSON`, j !== null, sent.body.slice(0, 120)) + eq(` title`, j && j.title, 'QQ音乐签到') + ok(` content 含明细`, !!(j && j.content && j.content.includes('任务中心签到')), j && j.content) + } + srv.close() + } + + /* 4. 无配置 */ + console.log('\n── 4. 两路都没配 ──') + { + const m = reloadWith({}) + const origLog = console.log + const lines = [] + console.log = (...a) => lines.push(a.join(' ')) + const r = await m.notify('T', 'C') + console.log = origLog + ok('ok = false', r.ok === false) + ok('提示「未配置任何通道」', lines.some(l => l.includes('未配置任何通道')), lines.join(' | ')) + } + + /* 5. 只有 webhook 挂、pushplus 通 —— 不应因一路失败而整体失败 */ + console.log('\n── 5. 双通道:webhook 失败但 pushplus 成功 ──') + { + const { srv, port } = await startServer(500) + const m = reloadWith({ + WEBHOOK_URL: `http://127.0.0.1:${port}/x`, + WEBHOOK_METHOD: 'POST', + PUSH_PLUS_TOKEN: 'faketoken_for_shape_only', + }) + // pushplus 会真的发出去 —— 这里只校验 webhook 那一半,避免污染真实账号 + const origLog = console.log + console.log = () => {} + const r = await m.notify('T', 'C') + console.log = origLog + const wh = r.channels.find(x => x.name === 'webhook') + ok('webhook 记为失败', wh.ok === false, JSON.stringify(wh)) + ok('channels 里有两路', r.channels.length === 2, JSON.stringify(r.channels.map(c => c.name))) + srv.close() + } + + console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`) + process.exit(fail === 0 ? 0 : 1) +})()