用户实跑结果:sendNotify 加载正常、WEBHOOK 三项前置条件全过、 直打平台两次都 204(含换行/中文/emoji 的完整正文也收下了)。 但 PUSH_PLUS_TOKEN 未设 —— 青龙根本不发 pushplus,所以收到的那条 pushplus 一定来自平台转发,不是脚本自己发的。 补第 5b 节把这条推理写进输出: - 204 只证明平台收下了,收到 ≠ 转发 - 中间还有两道门:事件要匹配到账号、账号下要有 enabled+active 的 pushplus 目标 - 给出两个 curl 判据(/api/notify-targets 与 /api/events?category=notify) 以及 deliveries=N 的读法 - 第 5 节在 PUSH_PLUS_TOKEN 未设时不再只是「跳过」,而是说明 这条消息必然是平台转发来的
255 lines
13 KiB
JavaScript
255 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
||
// cron: 0 0 1 1 *
|
||
// name: QQ音乐通知诊断
|
||
/**
|
||
* 通知链路诊断(青龙里跑:node scrip_diag_notify.js)
|
||
*
|
||
* 专治「pushplus 只收到一行标题」和「自定义发送通知消息失败」这两个现象。
|
||
* 逐段定位:sendNotify 加载 → env → webhook 配置 → 直打平台 /api/hooks/ql
|
||
* → 直连 pushplus 对比模板 → 走一遍完整 sendNotify。
|
||
*
|
||
* token 一律脱敏。只读配置 + 发测试消息,不改任何东西。
|
||
* 手动跑一次即可,cron 设成 1 月 1 日(等于不自动跑)。
|
||
*/
|
||
'use strict'
|
||
|
||
const https = require('https')
|
||
const http = require('http')
|
||
const mask = (v, n = 6) => (!v ? '(未设)' : `${v.slice(0, n)}…(len=${v.length})`)
|
||
const hr = t => console.log(`\n══════════ ${t} ══════════`)
|
||
|
||
const TITLE = 'QQ音乐签到'
|
||
const DESP = [
|
||
'✅ QQ音乐签到 · 全部完成',
|
||
'',
|
||
'🟡 1006764347:成长值 +0',
|
||
' ✓ 节目开始标记 已挂',
|
||
' △ 领奖·主题装扮 code 30005',
|
||
' △ 领奖·收听节目 未计次,待 App 内收听',
|
||
' · 无需处理 12 项:任务中心签到、挂件、装扮、听书行为…',
|
||
'',
|
||
'———',
|
||
'轮次: 00:01 / 06:02 双轮',
|
||
'时间: ' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }),
|
||
].join('\n')
|
||
|
||
/** 发一个请求,返回 {status, headers, body};超时/网络错误也返回而不抛 */
|
||
function req(url, { method = 'POST', headers = {}, body } = {}) {
|
||
return new Promise((resolve) => {
|
||
let u
|
||
try { u = new URL(url) } catch (e) { return resolve({ status: 0, headers: {}, body: `URL 非法: ${e.message}` }) }
|
||
const payload = body == null ? '' : (typeof body === 'string' ? body : JSON.stringify(body))
|
||
const lib = u.protocol === 'http:' ? http : https
|
||
const r = lib.request({
|
||
host: u.hostname, port: u.port || (u.protocol === 'http:' ? 80 : 443),
|
||
path: u.pathname + u.search, method,
|
||
headers: { ...headers, ...(payload ? { 'Content-Length': Buffer.byteLength(payload) } : {}) },
|
||
timeout: 15000,
|
||
}, (res) => {
|
||
const cs = []
|
||
res.on('data', c => cs.push(c))
|
||
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(cs).toString('utf8') }))
|
||
})
|
||
r.on('timeout', () => { r.destroy(); resolve({ status: 0, headers: {}, body: 'TIMEOUT(15s)' }) })
|
||
r.on('error', e => resolve({ status: 0, headers: {}, body: e.message }))
|
||
if (payload) r.write(payload)
|
||
r.end()
|
||
})
|
||
}
|
||
|
||
/* ────────────────── 1. sendNotify 加载 ────────────────── */
|
||
hr('1. sendNotify 加载')
|
||
let sn = null
|
||
try {
|
||
sn = require('./sendNotify')
|
||
console.log(' ✓ 加载成功,sendNotify =', typeof sn.sendNotify)
|
||
} catch (e) {
|
||
console.log(' ✗ 加载失败:', e.message.split('\n')[0])
|
||
console.log(' → 脚本里 require 失败会打一行日志并跳过推送,一条都发不出')
|
||
console.log(' → 修:docker exec -it qinglong pnpm install got')
|
||
}
|
||
|
||
/* ────────────────── 2. 通知 env ────────────────── */
|
||
hr('2. 通知 env')
|
||
const ENVS = ['PUSH_PLUS_TOKEN', 'PUSH_PLUS_USER', 'WEBHOOK_URL', 'WEBHOOK_BODY',
|
||
'WEBHOOK_METHOD', 'WEBHOOK_CONTENT_TYPE', 'WEBHOOK_HEADERS', 'SKIP_PUSH_TITLE']
|
||
const SENSITIVE = /TOKEN|URL|BODY|HEADERS/i
|
||
let envHit = 0
|
||
for (const k of ENVS) {
|
||
const v = process.env[k]
|
||
if (v) envHit++
|
||
console.log(` ${v ? '✓' : '·'} ${k.padEnd(20)} ${v ? (SENSITIVE.test(k) ? mask(v) : v) : '(未设)'}`)
|
||
}
|
||
if (!envHit) console.log(' ⚠️ 一个都没读到 —— 青龙「环境变量」是否保存/是否启用')
|
||
|
||
/* ────────────────── 3. webhook 配置分析 ────────────────── */
|
||
hr('3. webhook 配置分析')
|
||
const WU = process.env.WEBHOOK_URL || ''
|
||
const WB = process.env.WEBHOOK_BODY || ''
|
||
const WM = process.env.WEBHOOK_METHOD || ''
|
||
const WCT = process.env.WEBHOOK_CONTENT_TYPE || ''
|
||
const WH = process.env.WEBHOOK_HEADERS || ''
|
||
|
||
console.log(' URL 含 $title :', WU.includes('$title'))
|
||
console.log(' BODY 含 $title:', WB.includes('$title'))
|
||
console.log(' METHOD :', WM || '(空 → 静默跳过,不发也不报错)')
|
||
console.log(' CONTENT_TYPE :', WCT || '(空)')
|
||
|
||
// 三个前置条件(webhookNotify 逐行 return,缺一个就静默跳过)
|
||
if (!WU.includes('$title') && !WB.includes('$title')) {
|
||
console.log('\n ✗ 条件① 不过:URL 与 BODY 都没有 $title → 第一行就 resolve(),什么都不发')
|
||
}
|
||
if (!WM) {
|
||
console.log('\n ✗ 条件② 不过:WEBHOOK_METHOD 为空 → 走 else 分支 resolve(),什么都不发')
|
||
}
|
||
|
||
// body 解析复刻:parseString 靠「换行 + \w+:」断值,单行 JSON 会被整段吞掉
|
||
if (WB) {
|
||
console.log('\n --- BODY 解析复刻(sendNotify 的 parseString)---')
|
||
const regex = /(\w+):\s*((?:(?!\n\w+:).)*)/g
|
||
const matches = {}
|
||
let m
|
||
while ((m = regex.exec(WB)) !== null) { if (!matches[m[1]]) matches[m[1]] = m[2].trim() }
|
||
const keys = Object.keys(matches)
|
||
console.log(' 解析出:', keys.length ? keys.join(', ') : '(0 个 key)')
|
||
if (keys.length === 0) {
|
||
console.log(' ✗ 条件③ 不过:BODY 非空但解析出 0 个 key')
|
||
console.log(' 单行 JSON 会被整段吞掉(已实测),最终发出去的是 {}')
|
||
console.log(' → 平台 JSON.parse("{}") 得到空对象 → 400 title 与 content 不能同时为空')
|
||
console.log(' 修:BODY 改成多行 —— title:$title 换行 content:$content')
|
||
}
|
||
}
|
||
|
||
// headers 解析复刻:split('\n'),若 env 里是字面量 \n(两字符)就分不开
|
||
if (WH) {
|
||
console.log('\n --- HEADERS 解析复刻(sendNotify 的 parseHeaders)---')
|
||
const lines = WH.split('\n')
|
||
console.log(' 按换行切出:', lines.length, '行', lines.length === 1 && WH.includes('\\n') ? '(⚠️ 疑似字面量 \\n)' : '')
|
||
for (const line of lines) {
|
||
const i = line.indexOf(':')
|
||
if (i < 0) { console.log(' ✗ 无冒号,被丢弃:', line.slice(0, 40)); continue }
|
||
const k = line.slice(0, i).trim().toLowerCase()
|
||
const v = line.slice(i + 1).trim()
|
||
console.log(` ${k} = ${/token|key|secret/i.test(k) ? mask(v) : v}`)
|
||
if (v.includes('\\n')) console.log(' ⚠️ 值里含字面量 \\n —— 整行没被切开,token 会带上后半段')
|
||
}
|
||
}
|
||
|
||
/* ────────────────── 4. 直打平台 /api/hooks/ql ────────────────── */
|
||
hr('4. 直打平台 /api/hooks/ql')
|
||
if (!WU) {
|
||
console.log(' · WEBHOOK_URL 未设,跳过')
|
||
} else {
|
||
// 还原 sendNotify 实际会请求的 URL($title/$content 替换后)
|
||
const finalUrl = WU.replaceAll('$title', encodeURIComponent(TITLE)).replaceAll('$content', encodeURIComponent(DESP))
|
||
let host = '?', path = '?'
|
||
try { const u = new URL(finalUrl); host = u.host; path = u.pathname } catch { /* ignore */ }
|
||
console.log(' 目标:', host + path)
|
||
console.log(' query 长度:', (() => { try { return new URL(finalUrl).search.length } catch { return '?' } })(),
|
||
'(把正文塞进 URL 会超长,平台只读 body)')
|
||
|
||
// 复刻 parseHeaders
|
||
const hdrs = {}
|
||
if (WH) for (const line of WH.split('\n')) {
|
||
const i = line.indexOf(':')
|
||
if (i > 0) hdrs[line.slice(0, i).trim().toLowerCase()] = line.slice(i + 1).trim()
|
||
}
|
||
|
||
;(async () => {
|
||
console.log('\n ── 用 sendNotify 相同的头 + 一个最小合法 body 试一次 ──')
|
||
const probe = { title: '诊断探针', content: '来自 scrip_diag_notify.js 的连通性测试,可忽略' }
|
||
const r1 = await req(finalUrl, {
|
||
method: WM || 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...hdrs },
|
||
body: JSON.stringify(probe),
|
||
})
|
||
console.log(` HTTP ${r1.status} body=${r1.body.slice(0, 200) || '(空)'}`)
|
||
if (r1.status === 204) {
|
||
console.log(' ✓ 平台已接收(204 = 成功,且不写 event_logs)')
|
||
console.log(' ⚠️ 但 sendNotify 的成功判据是 statusCode !== 200 → 204 会被它当成失败')
|
||
console.log(' 「自定义发送通知消息失败!!」这句是假警报,实际投递成功了')
|
||
} else if (r1.status === 401) {
|
||
console.log(' ✗ 401:x-sh-hook-token 不对,或 WEBHOOK_HEADERS 没被正确解析')
|
||
} else if (r1.status === 403) {
|
||
console.log(' ✗ 403:来源 IP 不在白名单(只放 127.0.0.1 与 172.16/12),或回环带了 XFF')
|
||
} else if (r1.status === 400) {
|
||
console.log(' ✗ 400:body 不是合法 JSON,或 title/content 全空')
|
||
} else if (r1.status === 429) {
|
||
console.log(' ✗ 429:超过 60/分 或 1000/天')
|
||
} else if (r1.status === 0) {
|
||
console.log(' ✗ 连不上:', r1.body, '(容器网络/域名解析)')
|
||
}
|
||
|
||
console.log('\n ── 再发一条含中文与换行的真实正文(验证 JSON 转义)──')
|
||
const r2 = await req(finalUrl, {
|
||
method: WM || 'POST',
|
||
headers: { 'Content-Type': 'application/json', ...hdrs },
|
||
body: JSON.stringify({ title: TITLE, content: DESP }),
|
||
})
|
||
console.log(` HTTP ${r2.status} body=${r2.body.slice(0, 200) || '(空)'}`)
|
||
if (r2.status === 204) console.log(' ✓ 完整正文(含换行/中文/emoji)平台也收下了')
|
||
else if (r2.status !== r1.status) console.log(' ⚠️ 与上一条状态不同 → 正文内容触发了额外校验')
|
||
|
||
/* ────────── 5. 直连 pushplus 模板对比 ────────── */
|
||
hr('5. 直连 pushplus(模板对比)')
|
||
const PT = process.env.PUSH_PLUS_TOKEN || ''
|
||
if (!PT) {
|
||
console.log(' · PUSH_PLUS_TOKEN 未设 —— 青龙侧不发 pushplus')
|
||
console.log(' · 若你收到过 pushplus 消息,那是平台(script-helper)转发来的')
|
||
console.log(' → 说明平台侧的通知目标配好了,问题只在「正文被折叠」')
|
||
console.log(' → 平台用 template=txt(不折叠),青龙 sendNotify 用 html(折叠)')
|
||
} else {
|
||
for (const tpl of ['txt', 'html']) {
|
||
const content = tpl === 'html' ? DESP.replace(/\n/g, '<br>') : DESP
|
||
const r = await req('https://www.pushplus.plus/send', {
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: { token: PT, title: `${TITLE}(${tpl} 测试)`, content, template: tpl },
|
||
})
|
||
let j = {}
|
||
try { j = JSON.parse(r.body) } catch { /* 非 JSON */ }
|
||
console.log(` [${tpl}] HTTP ${r.status} code=${j.code} msg=${j.msg || ''} content=${content.length}字`)
|
||
}
|
||
console.log('\n → 微信里收到两条,对比哪条正文完整:')
|
||
console.log(' · txt 完整 / html 只有一行 → 换 template=txt')
|
||
console.log(' · 两条都只有一行 → 微信通知卡片本来就折叠,点开才展开全文')
|
||
console.log(' · 两条都收不到 → PUSH_PLUS_TOKEN 或实名状态有问题')
|
||
}
|
||
|
||
/* ────────── 5b. 平台侧有没有把事件转发出去 ────────── */
|
||
hr('5b. 平台侧转发检查')
|
||
const base = (() => { try { const u = new URL(finalUrl); return `${u.protocol}//${u.host}` } catch { return '' } })()
|
||
console.log(' 平台基址:', base || '(无法从 WEBHOOK_URL 推出)')
|
||
console.log('')
|
||
console.log(' 第 4 节是 204,说明平台「收下了」。但收到 ≠ 转发,中间还有两道门:')
|
||
console.log(' ① 事件要匹配到你的账号(notify_events.user_id 非空,或走 owner 原文透传)')
|
||
console.log(' ② 你的账号下要有 enabled=1 且 status=active 的 pushplus 目标')
|
||
console.log('')
|
||
console.log(' 验证方式(浏览器登录平台后打开开发者工具,或用 curl 带 cookie):')
|
||
if (base) {
|
||
console.log(` curl -s "${base}/api/notify-targets" -H "cookie: <你的会话 cookie>"`)
|
||
console.log(' → items 为空 = 没有目标,平台收到也不会转发(这就是只收到标题的原因)')
|
||
console.log(` curl -s "${base}/api/events?category=notify&limit=10" -H "cookie: <你的会话 cookie>"`)
|
||
console.log(' → 能看到 action=notify.send 的记录,message 里 deliveries=N')
|
||
console.log(' → deliveries=0 表示匹配到了事件但没有任何目标可投')
|
||
}
|
||
console.log('')
|
||
console.log(' 一句话判据:')
|
||
console.log(' · 平台 /api/notify-targets 为空 → 去平台「通知」页新建一个 pushplus 目标')
|
||
console.log(' · 有目标但 events 里 deliveries=0 → 目标被禁用,或事件没匹配到账号')
|
||
|
||
/* ────────── 6. 走一遍完整 sendNotify ────────── */
|
||
hr('6. 完整 sendNotify')
|
||
if (!sn) {
|
||
console.log(' 跳过(未加载)')
|
||
} else {
|
||
console.log(' 调用 sendNotify("QQ音乐签到", desp)…\n')
|
||
await sn.sendNotify(TITLE, DESP)
|
||
console.log('\n 对照第 4 节:')
|
||
console.log(' · 若这里出现「自定义发送通知消息失败」但第 4 节是 204 → 假警报,投递成功')
|
||
console.log(' · 若第 4 节 401/403 → webhook 头或 IP 的问题,与 sendNotify 无关')
|
||
console.log(' · 已修版本会打「成功🎉 HTTP 204」;仍打失败说明仓库还没更新到这个版本')
|
||
}
|
||
hr('完成')
|
||
})().catch(e => console.log(' 异常:', e.message))
|
||
}
|