2026-11-09 09:18:58 +08:00
|
|
|
|
// 通知模块:Windows Toast + 提示音 + 各种 webhook 转发
|
|
|
|
|
|
const path = require('path');
|
|
|
|
|
|
const { execFile } = require('child_process');
|
|
|
|
|
|
|
|
|
|
|
|
function toast(title, body, soundRepeat) {
|
|
|
|
|
|
const ps1 = path.join(__dirname, 'toast.ps1');
|
|
|
|
|
|
const exe = process.platform === 'win32' ? (process.env.DSH_PWSH || 'powershell') : null;
|
|
|
|
|
|
if (!exe) return;
|
|
|
|
|
|
const args = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1, title, body, String(soundRepeat)];
|
|
|
|
|
|
try {
|
|
|
|
|
|
// stdio:'ignore' keeps this working even in restricted environments (no pipes needed)
|
|
|
|
|
|
const child = execFile(exe, args, { windowsHide: true, stdio: 'ignore' }, () => {});
|
|
|
|
|
|
if (child && child.unref) child.unref();
|
|
|
|
|
|
} catch (e) { /* 通知失败不影响监听 */ }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function postJson(url, payload, headers = {}) {
|
|
|
|
|
|
const r = await fetch(url, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'content-type': 'application/json', ...headers },
|
|
|
|
|
|
body: JSON.stringify(payload),
|
|
|
|
|
|
signal: AbortSignal.timeout(10000)
|
|
|
|
|
|
});
|
|
|
|
|
|
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function sendWebhook(hook, title, text, extra) {
|
|
|
|
|
|
const type = (hook.type || 'custom').toLowerCase();
|
2026-11-09 09:42:19 +08:00
|
|
|
|
// url 支持 "env:变量名" —— 密钥不落在配置文件里(Obsidian 库会同步/进 Git)
|
|
|
|
|
|
const url = typeof hook.url === 'string' && hook.url.startsWith('env:')
|
|
|
|
|
|
? process.env[hook.url.slice(4)]
|
|
|
|
|
|
: hook.url;
|
|
|
|
|
|
if (!url) throw new Error('webhook.url 为空(或 env: 指定的环境变量未设置)');
|
2026-11-09 09:18:58 +08:00
|
|
|
|
|
|
|
|
|
|
switch (type) {
|
|
|
|
|
|
case 'feishu': // 飞书自定义机器人
|
|
|
|
|
|
return postJson(url, { msg_type: 'text', content: { text: `${title}\n${text}` } });
|
|
|
|
|
|
|
|
|
|
|
|
case 'wecom': // 企业微信群机器人
|
|
|
|
|
|
case 'dingtalk': // 钉钉自定义机器人
|
|
|
|
|
|
return postJson(url, { msgtype: 'text', text: { content: `${title}\n${text}` } });
|
|
|
|
|
|
|
|
|
|
|
|
case 'bark': { // 手机推送 iOS: https://api.day.app/你的KEY
|
|
|
|
|
|
const base = url.replace(/\/+$/, '');
|
|
|
|
|
|
const r = await fetch(`${base}/${encodeURIComponent(title)}/${encodeURIComponent(text)}?group=Goofish&level=timeSensitive`, {
|
|
|
|
|
|
signal: AbortSignal.timeout(10000)
|
|
|
|
|
|
});
|
|
|
|
|
|
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'serverchan': { // Server酱 https://sctapi.ftqq.com/你的KEY.send
|
|
|
|
|
|
const body = new URLSearchParams({ title, desp: text });
|
|
|
|
|
|
const r = await fetch(url, {
|
|
|
|
|
|
method: 'POST',
|
|
|
|
|
|
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
|
|
|
|
body,
|
|
|
|
|
|
signal: AbortSignal.timeout(10000)
|
|
|
|
|
|
});
|
|
|
|
|
|
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'pushplus': { // http://www.pushplus.plus/send
|
|
|
|
|
|
return postJson(url, { token: hook.token || '', title, content: text, template: 'txt' });
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
case 'custom': {
|
|
|
|
|
|
const method = (hook.method || 'POST').toUpperCase();
|
|
|
|
|
|
let body;
|
|
|
|
|
|
if (method === 'GET') {
|
|
|
|
|
|
body = undefined;
|
|
|
|
|
|
} else if (typeof hook.body === 'string') {
|
|
|
|
|
|
body = hook.body.replaceAll('{{title}}', title).replaceAll('{{text}}', text).replaceAll('{{url}}', extra?.url || '');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
body = JSON.stringify({ title, text, ...extra });
|
|
|
|
|
|
}
|
|
|
|
|
|
const r = await fetch(url, {
|
|
|
|
|
|
method,
|
|
|
|
|
|
headers: { 'content-type': typeof hook.body === 'string' ? 'text/plain; charset=utf-8' : 'application/json', ...(hook.headers || {}) },
|
|
|
|
|
|
body: method === 'GET' ? undefined : body,
|
|
|
|
|
|
signal: AbortSignal.timeout(10000)
|
|
|
|
|
|
});
|
|
|
|
|
|
return { status: r.status, text: (await r.text()).slice(0, 300) };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
default:
|
|
|
|
|
|
throw new Error(`未知 webhook 类型: ${type}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function notify(cfg, title, text, extra = {}, logger = console.log) {
|
|
|
|
|
|
if (cfg.notify?.toast) {
|
|
|
|
|
|
toast(title, text, cfg.notify?.repeatSound ?? 1);
|
|
|
|
|
|
}
|
|
|
|
|
|
const hooks = cfg.webhooks || [];
|
|
|
|
|
|
if (!hooks.length) return;
|
|
|
|
|
|
await Promise.all(hooks.map(async (h) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await sendWebhook(h, title, text, extra);
|
|
|
|
|
|
logger(`[webhook] ${h.type} ${h.url.slice(0, 60)} -> ${r.status}`);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
logger(`[webhook] ${h.type} 失败: ${e.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = { notify, toast, sendWebhook };
|