fix(notify): 平台回 204 被误判为失败 + 诊断工具改名 scrip_
三个改动: 1) sendNotify.js 的 webhook 判据从 statusCode !== 200 改成 2xx 一律成功。 平台 /api/hooks/ql 按设计回 204(表示已接收且不写 event_logs), 原判据把 204 当失败 → 每轮日志都打「自定义发送通知消息失败!!」, 而投递其实成功了。这是纯假警报,也是这次误判的根因。 同时把状态码打进日志,真失败时能直接看出是 401/403/400/429。 2) _diag_notify.js → scrip_diag_notify.js。 订阅白名单是 scrip_,不带前缀的文件拉不下来,工具等于不存在。 加 // cron: 0 0 1 1 * 与 // name:,避免青龙自动建任务时落随机 cron。 正文补到六段:sendNotify 加载 / env / webhook 三前置条件与 body+headers 解析复刻 / 直打平台按状态码定位 / pushplus txt-vs-html 模板对比 / 完整 sendNotify 对照。 3) 文档补齐平台侧的两道硬校验:来源 IP 必须 127.0.0.1 或 172.16/12 (走公网域名会变成反代地址 → 403),回环带 XFF 一律 403。 给出指向平台的完整 env 写法(WEBHOOK_BODY 必须多行,单行 JSON 会被 parseString 整段吞掉 —— 已实测解析结果为 0 个 key)。 验证:node --check(sendNotify + 诊断)/ 5 个 mock 状态码用例 (204/401/403/400/429)全部被正确识别 / _probe_meta 四文件 PASS
This commit is contained in:
228
scrip_diag_notify.js
Normal file
228
scrip_diag_notify.js
Normal file
@@ -0,0 +1,228 @@
|
||||
#!/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 未设,跳过')
|
||||
} 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 或实名状态有问题')
|
||||
}
|
||||
|
||||
/* ────────── 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 无关')
|
||||
}
|
||||
hr('完成')
|
||||
})().catch(e => console.log(' 异常:', e.message))
|
||||
}
|
||||
Reference in New Issue
Block a user