Files
ql-scripts/qqmusic/test_notify.js
zhuhjay 6a64a7de6f fix(qqmusic): 通知改为脚本自实现,断开对青龙 sendNotify 的依赖
真机定位(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 展示限制的
三种官方解法与代价对照。
2026-11-07 01:37:59 +08:00

171 lines
7.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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)
})()