// 通知模块: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 rawUrl = hook.url; const url = typeof rawUrl === 'string' && rawUrl.startsWith('env:') ? process.env[rawUrl.slice(4)] : rawUrl; const needsUrl = !['wecomapp', 'email'].includes(type); if (needsUrl && !url) throw new Error('webhook.url 为空(或 env: 指定的环境变量未设置)'); switch (type) { case 'wecomapp': { // 企业微信自建应用:推给自己单聊,需管理员在后台建应用 const secret = hook.corpsecret && hook.corpsecret.startsWith('env:') ? process.env[hook.corpsecret.slice(4)] : hook.corpsecret; if (!hook.corpid || !secret || !hook.agentid) throw new Error('wecomapp 需要 corpid / corpsecret / agentid'); const tr = await fetch(`https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=${encodeURIComponent(hook.corpid)}&corpsecret=${encodeURIComponent(secret)}`, { signal: AbortSignal.timeout(10000) }); const tj = await tr.json(); if (!tj.access_token) throw new Error(`gettoken 失败: ${tj.errcode} ${tj.errmsg}`); return postJson(`https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token=${tj.access_token}`, { touser: hook.touser || '@all', msgtype: 'text', agentid: Number(hook.agentid), text: { content: `${title}\n${text}` }, safe: 0 }); } case 'email': { // 邮件:QQ/163 等 SMTP,微信里能收到邮件提醒 const pass = hook.passEnv ? process.env[hook.passEnv] : hook.pass; if (!hook.smtp || !hook.from || !hook.to) throw new Error('email 需要 smtp / from / to'); if (!pass) throw new Error('email 缺少密码/授权码'); const ps1 = path.join(__dirname, 'send-mail.ps1'); const args = [ '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', ps1, '-Smtp', hook.smtp, '-Port', String(hook.port || 465), '-From', hook.from, '-To', hook.to, '-User', hook.user || hook.from, '-Pass', pass, '-Subject', title, '-Body', text ]; const code = await new Promise((res, rej) => { try { execFile('powershell', args, { windowsHide: true }, (err) => res(err ? (err.code ?? 1) : 0)); } catch (e) { rej(e); } }); if (code !== 0) throw new Error(`Send-MailMessage 失败 (exit ${code})`); return { status: 200, text: 'smtp ok' }; } 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 };