// 通知模块: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(); // 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: 指定的环境变量未设置)'); 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 };