refactor(common): 通知实现搬进 common.js,所有脚本一行 require 即可用
上一版把 notify 内联在 qqmusic 脚本里,只解决了单个脚本。这次搬到仓库根的
common.js,后续任何脚本 require('../common') 就能发通知。
common.js 新增导出:
notify(title, content, opts) webhook + pushplus 双通道,任一成功即 ok,永不抛错
notifyConfig(env) 读 env 并给默认值(可注入替身,方便测试)
httpPost(url, headers, body) 通用 POST,http/https 自适应
parseHookHeaders(raw) 一行一个 Key: Value
buildHookBody(t, c, tpl, ct) 拼 body,JSON 不合法就重建
Env 保持原样未动(jd 与 xiequ 在用)。
顺带删掉第 1 行的 require('node-fetch') —— 它是死代码:Env 内部所有 HTTP 走的是
require('got') + require('tough-cookie'),fetch 只以 $task.fetch(...) 的形式出现在
QuantumultX 分支(宿主 API,不是 node-fetch)。留着它反而会在容器缺 node-fetch 时
让整个文件 require 抛错,连带 jd / xiequ 一起挂。
qqmusic 脚本从 125 行内联实现缩成一行 require('../common'),删掉重复的
httpPost / parseHookHeaders / buildHookBody / notify 四份副本。
测试从 qqmusic/test_notify.js 挪到仓库根 test_common_notify.js,断言从 38 加到 69:
- parseHookHeaders 九种输入(新增重复 key 覆盖)
- buildHookBody 五种(新增自定义模板 + urlencoded + text/plain)
- notifyConfig 十项默认值与 trim
- notify 八种状态(200/204/201/401/403/400/429/连不上)
- 无配置、双通道互不牵连
- Env 兼容性(确认 node-fetch 已移除、老接口都在)
端到端已验证:common.js 的 notify 发出的 content 与原始 desp 逐字相同(10 行完整),
title 正确,header 带 token,HTTP 204 判为成功。
README(根 + qqmusic)新增 common.js 公共库章节,列出 notify 支持的八个环境变量
与「为什么不能放 sendNotify.js」的 deps 覆盖原因。
This commit is contained in:
39
README.md
39
README.md
@@ -25,6 +25,45 @@
|
||||
> **文件名约定**:订阅白名单是 `scrip_`,所有脚本文件必须带该前缀才会被拉取。
|
||||
> 详见各目录下的 README。
|
||||
|
||||
## 公共库 `common.js`
|
||||
|
||||
仓库根的 `common.js` 是**依赖文件**(订阅「依赖文件」填 `common` 就会拉下来),
|
||||
提供两样东西:
|
||||
|
||||
```js
|
||||
const { Env, notify } = require('../common')
|
||||
|
||||
// 1. Env:四端环境封装(QuantumultX / Surge / Loon / Node),jd 与 xiequ 在用
|
||||
const $ = new Env('我的脚本')
|
||||
|
||||
// 2. notify:发通知,webhook + pushplus 双通道,任一成功即 ok,永不抛错
|
||||
const r = await notify('标题', '正文')
|
||||
// → { ok: true, channels: [{name:'webhook', ok:true, detail:'HTTP 204'}, ...] }
|
||||
```
|
||||
|
||||
`notify` 读的环境变量:
|
||||
|
||||
| 变量 | 说明 |
|
||||
|---|---|
|
||||
| `WEBHOOK_URL` | 自定义 webhook(如平台 `/api/hooks/ql`) |
|
||||
| `WEBHOOK_METHOD` | 默认 `POST` |
|
||||
| `WEBHOOK_CONTENT_TYPE` | 默认 `application/json` |
|
||||
| `WEBHOOK_BODY` | 支持 `$title` / `$content` 占位;不填则按 Content-Type 给默认形状 |
|
||||
| `WEBHOOK_HEADERS` | 一行一个 `Key: Value`,**必须是真换行** |
|
||||
| `PUSH_PLUS_TOKEN` | pushplus 直发 |
|
||||
| `PUSH_PLUS_USER` | pushplus 群组编码(一对多) |
|
||||
| `PUSH_PLUS_TEMPLATE` | 默认 `txt`(不转义 html、不折叠) |
|
||||
|
||||
**为什么不放在 `sendNotify.js` 里**:青龙订阅复制文件时 `/ql/data/deps/*` 会**最后覆盖**,
|
||||
而 `deps` 里正好有一份面板自带的 `sendNotify.js`。改那个文件永远不生效。
|
||||
`common.js` 不在 `deps` 里,所以安全。详见 [qqmusic/README.md](qqmusic/README.md) 的「坑 0」。
|
||||
|
||||
自检:
|
||||
|
||||
```bash
|
||||
node test_common_notify.js # 69 条断言,含八种 HTTP 状态码
|
||||
```
|
||||
|
||||
|
||||
## 安装使用
|
||||
|
||||
|
||||
@@ -233,8 +233,15 @@ docker exec -it qinglong sh -c '
|
||||
|
||||
两个 md5 相同 = 被覆盖了。
|
||||
|
||||
**结论:本脚本不再 `require('../sendNotify')`。** 通知逻辑内联在
|
||||
`scrip_qqmusic_checkin.js` 的 `notify()` 里(约 90 行,零依赖),行为完全由仓库决定。
|
||||
**结论:通知实现放在仓库根的 `common.js`,脚本 `require('../common')` 取用。**
|
||||
|
||||
```js
|
||||
const { notify } = require('../common')
|
||||
await notify('QQ音乐签到', desp) // → { ok, channels: [{name, ok, detail}] }
|
||||
```
|
||||
|
||||
`common.js` 不在 `deps` 里(`deps` 只有 `notify.py` 和 `sendNotify.js`),所以安全。
|
||||
后续任何脚本想发通知,同样一行 `require('../common')` 即可,不用再碰 `sendNotify.js`。
|
||||
|
||||
### 坑 1:`sendNotify` 的三个静默 `resolve()`
|
||||
|
||||
@@ -275,12 +282,12 @@ pushplus 官方给了三种解法([文档](https://pushplus.plus/doc/help/show
|
||||
### 自检
|
||||
|
||||
```bash
|
||||
# 纯函数 + 六种状态码(200/204/201/401/403/400/429)+ 无配置 + 双通道
|
||||
node qqmusic/test_notify.js
|
||||
# common.js 的 notify:69 条断言(纯函数 + 八种状态码 + Env 兼容性)
|
||||
node test_common_notify.js
|
||||
```
|
||||
|
||||
```
|
||||
RESULT: PASS (38 passed, 0 failed)
|
||||
RESULT: PASS (69 passed, 0 failed)
|
||||
```
|
||||
|
||||
### 指向平台的完整配置
|
||||
@@ -321,15 +328,16 @@ 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 ../test_common_notify.js # 通知实现(69 条断言,见仓库根 README)
|
||||
node ../_probe_meta.js # 青龙元数据注解
|
||||
```
|
||||
|
||||
`test_notify.js` 覆盖:
|
||||
通知相关的测试在仓库根 `test_common_notify.js`(因为实现搬到了 `common.js`),
|
||||
覆盖:
|
||||
|
||||
- `parseHookHeaders`:多行 / 空行 / 无冒号 / 值含冒号 / 字面量 `\n` 不切开
|
||||
- `buildHookBody`:内容含双引号、反斜杠、制表符时仍产出合法 JSON
|
||||
- `notify`:mock 服务测 **200 / 204 / 201 / 401 / 403 / 400 / 429** 七种状态码
|
||||
- 无配置、双通道部分失败
|
||||
- `parseHookHeaders`:多行 / 空行 / 无冒号 / 值含冒号 / 重复 key / 字面量 `\n` 不切开
|
||||
- `buildHookBody`:内容含双引号、反斜杠、制表符、多行时仍产出合法 JSON
|
||||
- `notify`:mock 服务测 **200 / 204 / 201 / 401 / 403 / 400 / 429 / 连不上** 八种
|
||||
- 无配置、双通道部分失败、`Env` 兼容性(jd / xiequ 在用)
|
||||
|
||||
|
||||
@@ -50,25 +50,19 @@ 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 真机定位):
|
||||
/* ---------------- 通知:复用仓库根 common.js 的 notify() ----------------
|
||||
* 为什么不 require 青龙的 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。
|
||||
* $dir_dep = /ql/data/deps,share.sh:228 初始化时往里面拷了一份面板自带的 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()
|
||||
*
|
||||
* common.js 不在 deps 里(deps 只有 notify.py 和 sendNotify.js),所以把通知实现放
|
||||
* 那里是安全的,且后续任何脚本都能 require('../common') 直接用。 */
|
||||
const { notify } = require('../common')
|
||||
|
||||
|
||||
function time33(s) {
|
||||
let h = 5381
|
||||
@@ -746,107 +740,6 @@ 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 —— 走公网也不泄露凭证。
|
||||
@@ -991,4 +884,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, notify, buildHookBody, parseHookHeaders }
|
||||
module.exports = { time33, zzc, QMClient, parseAccounts, pushCardResult }
|
||||
|
||||
@@ -1,170 +0,0 @@
|
||||
#!/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)
|
||||
})()
|
||||
210
test_common_notify.js
Normal file
210
test_common_notify.js
Normal file
@@ -0,0 +1,210 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* test_common_notify.js —— 验证 common.js 的 notify 实现
|
||||
*
|
||||
* 覆盖:
|
||||
* 1. parseHookHeaders —— 多行 / 空行 / 无冒号 / 重复 key / 字面量 \n
|
||||
* 2. buildHookBody —— JSON 转义(引号/反斜杠/制表符)、urlencoded、纯文本、自定义模板
|
||||
* 3. notify —— mock 服务测 200/204/201/401/403/400/429/0
|
||||
* 4. notifyConfig —— env 读取与默认值
|
||||
* 5. 无配置 / 双通道部分失败
|
||||
* 6. Env 兼容性 —— 老接口(jd / xiequ 在用)没被破坏
|
||||
*
|
||||
* 用法: node test_common_notify.js
|
||||
* 退出码 0 = 全过
|
||||
*/
|
||||
'use strict'
|
||||
const http = require('http')
|
||||
const m = require('./common.js')
|
||||
|
||||
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. parseHookHeaders ══════════ */
|
||||
console.log('\n── 1. parseHookHeaders ──')
|
||||
eq('单行', JSON.stringify(m.parseHookHeaders('X-A: 1')), '{"X-A":"1"}')
|
||||
eq('两行', JSON.stringify(m.parseHookHeaders('X-A: 1\nX-B: 2')), '{"X-A":"1","X-B":"2"}')
|
||||
eq('空行忽略', JSON.stringify(m.parseHookHeaders('X-A: 1\n\n\nX-B: 2')), '{"X-A":"1","X-B":"2"}')
|
||||
eq('无冒号忽略', JSON.stringify(m.parseHookHeaders('garbage\nX-A: 1')), '{"X-A":"1"}')
|
||||
eq('值含冒号', JSON.stringify(m.parseHookHeaders('X-A: a:b:c')), '{"X-A":"a:b:c"}')
|
||||
eq('空串', JSON.stringify(m.parseHookHeaders('')), '{}')
|
||||
eq('值前后空格 trim', JSON.stringify(m.parseHookHeaders(' X-A : v ')), '{"X-A":"v"}')
|
||||
eq('字面量 \\n 不切开', Object.keys(m.parseHookHeaders('X-A: 1\\nX-B: 2')).length, 1)
|
||||
eq('重复 key 后者覆盖', m.parseHookHeaders('X-A: 1\nX-A: 2')['X-A'], '2')
|
||||
|
||||
/* ══════════ 2. buildHookBody ══════════ */
|
||||
console.log('\n── 2. buildHookBody ──')
|
||||
{
|
||||
const b = m.buildHookBody('标题', '第一行\n第二行', '', 'application/json')
|
||||
let j = null; try { j = JSON.parse(b) } catch { /* */ }
|
||||
ok('默认模板产出合法 JSON', j !== null, b)
|
||||
eq('title', j && j.title, '标题')
|
||||
eq('换行保留', j && j.content, '第一行\n第二行')
|
||||
}
|
||||
{
|
||||
const b = m.buildHookBody('T', '他说"你好"然后走了', 'title:$title\ncontent:$content', 'application/json')
|
||||
let j = null; try { j = JSON.parse(b) } catch { /* */ }
|
||||
ok('内容含双引号仍合法', j !== null, b)
|
||||
eq('引号原样保留', j && j.content, '他说"你好"然后走了')
|
||||
}
|
||||
{
|
||||
const b = m.buildHookBody('T', 'C:\\path\\to\tfile', '', 'application/json')
|
||||
let j = null; try { j = JSON.parse(b) } catch { /* */ }
|
||||
ok('含反斜杠/制表符仍合法', j !== null, b)
|
||||
eq('反斜杠保留', j && j.content, 'C:\\path\\to\tfile')
|
||||
}
|
||||
{
|
||||
const b = m.buildHookBody('T', 'a\nb', 'title:$title\ncontent:$content', 'application/json')
|
||||
let j = null; try { j = JSON.parse(b) } catch { /* */ }
|
||||
ok('多行模板 + 多行内容 仍合法', j !== null, b)
|
||||
eq('多行内容完整', j && j.content, 'a\nb')
|
||||
}
|
||||
{
|
||||
const b = m.buildHookBody('标题', 'a&b=c', '', 'application/x-www-form-urlencoded')
|
||||
eq('urlencoded 编码', b, 'title=%E6%A0%87%E9%A2%98&content=a%26b%3Dc')
|
||||
}
|
||||
{
|
||||
const b = m.buildHookBody('T', 'C', 'x=$title&y=$content', 'text/plain')
|
||||
eq('纯文本替换', b, 'x=T&y=C')
|
||||
}
|
||||
|
||||
/* ══════════ 3. notifyConfig ══════════ */
|
||||
console.log('\n── 3. notifyConfig ──')
|
||||
{
|
||||
const c = m.notifyConfig({})
|
||||
eq('默认 method', c.webhookMethod, 'POST')
|
||||
eq('默认 content-type', c.webhookCt, 'application/json')
|
||||
eq('默认 pushplus 模板', c.pushPlusTemplate, 'txt')
|
||||
eq('空 URL', c.webhookUrl, '')
|
||||
eq('空 token', c.pushPlusToken, '')
|
||||
}
|
||||
{
|
||||
const c = m.notifyConfig({
|
||||
WEBHOOK_URL: ' http://x/ ', WEBHOOK_METHOD: 'post',
|
||||
PUSH_PLUS_TOKEN: ' tk ', PUSH_PLUS_USER: 'grp', PUSH_PLUS_TEMPLATE: 'html',
|
||||
})
|
||||
eq('URL 被 trim', c.webhookUrl, 'http://x/')
|
||||
eq('method 大写', c.webhookMethod, 'POST')
|
||||
eq('token 被 trim', c.pushPlusToken, 'tk')
|
||||
eq('topic', c.pushPlusTopic, 'grp')
|
||||
eq('template 可改', c.pushPlusTemplate, 'html')
|
||||
}
|
||||
|
||||
/* ══════════ 4. notify(mock 服务) ══════════ */
|
||||
console.log('\n── 4. notify(mock 服务)──')
|
||||
|
||||
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 平台正常返回(★ 旧 sendNotify 误判为失败的就是这个)' },
|
||||
{ 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 r = await m.notify('QQ音乐签到', '✅ 全部完成\n ✓ 任务中心签到 +15', {
|
||||
silent: true,
|
||||
env: {
|
||||
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',
|
||||
},
|
||||
})
|
||||
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.wantOk) {
|
||||
ok(' 请求发出去了', seen.length === 1, `seen=${seen.length}`)
|
||||
const s = seen[0]
|
||||
ok(' method POST', s.method === 'POST', s.method)
|
||||
ok(' header 带 token', s.headers['x-sh-hook-token'] === 'tok123', s.headers['x-sh-hook-token'])
|
||||
let j = null; try { j = JSON.parse(s.body) } catch { /* */ }
|
||||
ok(' body 合法 JSON', j !== null, s.body.slice(0, 120))
|
||||
eq(' title', j && j.title, 'QQ音乐签到')
|
||||
ok(' content 含明细', !!(j && j.content && j.content.includes('任务中心签到')), j && j.content)
|
||||
}
|
||||
srv.close()
|
||||
}
|
||||
|
||||
/* 连不上 */
|
||||
console.log('\n── 4b. 连不上 ──')
|
||||
{
|
||||
const r = await m.notify('T', 'C', {
|
||||
silent: true,
|
||||
env: { WEBHOOK_URL: 'http://127.0.0.1:1/nope', WEBHOOK_METHOD: 'POST' },
|
||||
})
|
||||
const wh = r.channels.find(x => x.name === 'webhook')
|
||||
ok('连接失败记为 not ok', wh.ok === false, JSON.stringify(wh))
|
||||
ok('detail 标 ERR', wh.detail.includes('ERR'), wh.detail)
|
||||
}
|
||||
|
||||
/* ══════════ 5. 无配置 / 双通道 ══════════ */
|
||||
console.log('\n── 5. 无配置 ──')
|
||||
{
|
||||
const lines = []
|
||||
const orig = console.log
|
||||
console.log = (...a) => lines.push(a.join(' '))
|
||||
const r = await m.notify('T', 'C', { env: {} })
|
||||
console.log = orig
|
||||
ok('ok = false', r.ok === false)
|
||||
ok('提示「未配置任何通道」', lines.some(l => l.includes('未配置任何通道')), lines.join(' | '))
|
||||
ok('channels 有两路', r.channels.length === 2, JSON.stringify(r.channels.map(c => c.name)))
|
||||
}
|
||||
|
||||
console.log('\n── 5b. webhook 失败但 pushplus 成功 ──')
|
||||
{
|
||||
const { srv, port } = await startServer(500)
|
||||
// pushplus 那半会真发 —— 用假 token,pushplus 返回非 200 code,同样能验证「互不牵连」
|
||||
const r = await m.notify('T', 'C', {
|
||||
silent: true,
|
||||
env: { WEBHOOK_URL: `http://127.0.0.1:${port}/x`, WEBHOOK_METHOD: 'POST', PUSH_PLUS_TOKEN: 'fake_token_for_shape' },
|
||||
})
|
||||
const wh = r.channels.find(x => x.name === 'webhook')
|
||||
const pp = r.channels.find(x => x.name === 'pushplus')
|
||||
ok('webhook 记为失败', wh.ok === false, JSON.stringify(wh))
|
||||
ok('pushplus 那路也跑了', pp.detail !== 'PUSH_PLUS_TOKEN 未设', JSON.stringify(pp))
|
||||
srv.close()
|
||||
}
|
||||
|
||||
/* ══════════ 6. Env 兼容性 ══════════ */
|
||||
console.log('\n── 6. Env 兼容性(jd / xiequ 在用)──')
|
||||
{
|
||||
ok('Env 是函数', typeof m.Env === 'function')
|
||||
const e = new m.Env('测试脚本')
|
||||
eq('name', e.name, '测试脚本')
|
||||
ok('isNode()', e.isNode() === true)
|
||||
ok('有 get/post', typeof e.get === 'function' && typeof e.post === 'function')
|
||||
ok('有 getdata/setdata', typeof e.getdata === 'function' && typeof e.setdata === 'function')
|
||||
ok('有 log/msg/wait/done', ['log', 'msg', 'wait', 'done'].every(k => typeof e[k] === 'function'))
|
||||
// 关键:node-fetch 移除后,require 整个文件不该抛错
|
||||
ok('不再 require node-fetch', !require('fs').readFileSync('./common.js', 'utf8').includes("require('node-fetch')"))
|
||||
}
|
||||
|
||||
console.log(`\nRESULT: ${fail === 0 ? 'PASS' : `FAIL(${fail})`} (${pass} passed, ${fail} failed)`)
|
||||
process.exit(fail === 0 ? 0 : 1)
|
||||
})()
|
||||
Reference in New Issue
Block a user