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 展示限制的
三种官方解法与代价对照。
This commit is contained in:
@@ -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 会把换行变 <br>
|
||||
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 }
|
||||
|
||||
Reference in New Issue
Block a user