diff --git a/server/app/controller/notify.js b/server/app/controller/notify.js index c03014a..1456005 100644 --- a/server/app/controller/notify.js +++ b/server/app/controller/notify.js @@ -1,6 +1,4 @@ -const path = require('path') -const decryptAndExecuteAsync = require('../utils/decrypt-file') -const { sendServerChan, sendEmail } = require('../utils/notify') +const { sendServerChan, sendEmail, sendTg, sendWebhook } = require('../utils/notify') const { NotifyConfigDB, NotifyDB } = require('../utils/db-class') const notifyDB = new NotifyDB().getInstance() const notifyConfigDB = new NotifyConfigDB().getInstance() @@ -23,11 +21,11 @@ async function updateNotifyConfig({ res, request }) { await sendEmail(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知') break case 'tg': - let { sendTg } = await decryptAndExecuteAsync(path.join(__dirname, '../utils/plus.js')) || {} - console.log('sendTg: ', sendTg) - if (!sendTg) return res.fail({ msg: 'Plus专属功能点,请激活Plus' }) await sendTg(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知') break + case 'webhook': + await sendWebhook(noticeConfig[type], 'EasyNode通知测试', '这是一条测试通知') + break } await notifyConfigDB.update({}, { $set: noticeConfig }, { upsert: true }) return res.success({ msg: '测试通过 | 保存成功' }) diff --git a/server/app/db.js b/server/app/db.js index a697d30..625405f 100644 --- a/server/app/db.js +++ b/server/app/db.js @@ -122,6 +122,13 @@ async function initNotifyConfigDB() { tg: { token: '', chatId: '' + }, + webhook: { + url: '', + method: 'POST', + contentType: 'application/json', + headers: '', + template: '{\n "title": "{{title}}",\n "content": "{{content}}",\n "description": "{{content}}",\n "timestamp": "{{timestamp}}",\n "datetime": "{{datetime}}"\n}' } } if (notifyConfig) { diff --git a/server/app/utils/notify.js b/server/app/utils/notify.js index 6c1aeee..b0674b5 100644 --- a/server/app/utils/notify.js +++ b/server/app/utils/notify.js @@ -1,13 +1,132 @@ -const path = require('path') -const decryptAndExecuteAsync = require('./decrypt-file') const nodemailer = require('nodemailer') +const TelegramBot = require('node-telegram-bot-api') const axios = require('axios') +const FormData = require('form-data') const commonTemp = require('../template/commonTemp') const { NotifyDB, NotifyConfigDB } = require('./db-class') const notifyConfigDB = new NotifyConfigDB().getInstance() const notifyDB = new NotifyDB().getInstance() -function sendServerChan(sendKey, title, content) { +async function sendWebhook({ url, method = 'POST', contentType = 'application/json', headers = '', template = '' }, title, content) { + return new Promise((async (resolve, reject) => { + try { + logger.info('Webhook通知预发送: ', title) + + const timestamp = Date.now() + const datetime = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }) + + // JSON 字符串转义函数(用于在 JSON 字符串中安全替换变量) + const escapeJsonString = (str) => { + if (typeof str !== 'string') return str + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + } + + // 变量替换函数 + const replaceVars = (str, escapeForJson = false) => { + const t = escapeForJson ? escapeJsonString(title) : title + const c = escapeForJson ? escapeJsonString(content) : content + const dt = escapeForJson ? escapeJsonString(datetime) : datetime + return str + .replace(/\{\{title\}\}/g, t) + .replace(/\{\{content\}\}/g, c) + .replace(/\{\{timestamp\}\}/g, timestamp) + .replace(/\{\{datetime\}\}/g, dt) + } + + // 构建请求数据 + let data + const defaultData = { title, content, timestamp, datetime } + + if (template) { + // JSON 类型需要转义特殊字符 + const needEscape = contentType === 'application/json' + const replacedTemplate = replaceVars(template, needEscape) + if (contentType === 'application/json') { + try { + data = JSON.parse(replacedTemplate) + } catch (e) { + logger.warn('Webhook模板JSON解析失败,使用原始字符串: ', e.message) + data = replacedTemplate + } + } else { + data = replacedTemplate + } + } else { + data = defaultData + } + + // 解析自定义请求头 + let customHeaders = {} + if (headers) { + try { + customHeaders = JSON.parse(headers) + } catch (e) { + logger.warn('Webhook自定义请求头解析失败: ', e.message) + } + } + + // 构建请求配置 + const config = { + method: method.toUpperCase(), + url, + headers: { ...customHeaders } + } + + // 根据 Content-Type 处理请求数据 + if (method.toUpperCase() === 'GET') { + config.params = typeof data === 'object' ? data : { data } + } else { + switch (contentType) { + case 'application/json': + config.headers['Content-Type'] = 'application/json' + config.data = typeof data === 'object' ? data : JSON.parse(data) + break + case 'application/x-www-form-urlencoded': + config.headers['Content-Type'] = 'application/x-www-form-urlencoded' + if (typeof data === 'object') { + config.data = new URLSearchParams(data).toString() + } else { + config.data = data + } + break + case 'multipart/form-data': + const formData = new FormData() + if (typeof data === 'object') { + Object.keys(data).forEach(key => { + formData.append(key, String(data[key])) + }) + } else { + formData.append('data', data) + } + config.data = formData + config.headers = { ...config.headers, ...formData.getHeaders() } + break + case 'text/plain': + config.headers['Content-Type'] = 'text/plain' + config.data = typeof data === 'object' ? JSON.stringify(data) : data + break + default: + config.headers['Content-Type'] = contentType + config.data = data + } + } + + const response = await axios(config) + logger.info('Webhook通知发送成功: ', title, response.status) + resolve(response.data) + } catch (error) { + logger.error('Webhook通知发送失败: ', error.message) + reject(error) + } + })) +} + +async function sendServerChan(sendKey, title, content) { return new Promise((async (resolve, reject) => { try { logger.info('server酱通知预发送: ', title) @@ -28,7 +147,7 @@ function sendServerChan(sendKey, title, content) { } -function sendEmail({ service, user, pass }, title, content) { +async function sendEmail({ service, user, pass }, title, content) { return new Promise((async (resolve, reject) => { try { logger.info('邮箱通知预发送: ', title) @@ -55,6 +174,22 @@ function sendEmail({ service, user, pass }, title, content) { })) } +async function sendTg({ token, chatId }, title, content) { + return new Promise((async (resolve, reject) => { + try { + logger.info('Telegram通知预发送: ', title) + const bot = new TelegramBot(token) + let msg = `*${ title }*\n${ content }` + await bot.sendMessage(Number(chatId), msg, { parse_mode: 'Markdown' }) + logger.info('Telegram通知发送成功: ', title) + resolve() + } catch (error) { + reject(error) + logger.error('Telegram通知发送失败: ', error) + } + })) +} + // 异步发送通知 async function sendNoticeAsync(noticeAction, title, content) { try { @@ -81,10 +216,13 @@ async function sendNoticeAsync(noticeAction, title, content) { case 'tg': let { token, chatId } = notifyConfig['tg'] if (!token || !chatId) return logger.info('未发送Telegram通知, 未配置token或chatId: ', { token, chatId }) - let { sendTg } = await decryptAndExecuteAsync(path.join(__dirname, 'plus.js')) - if (!sendTg) return logger.info('未发送Telegram通知, Plus功能解析失败') await sendTg({ token, chatId }, title, content) break + case 'webhook': + let { url, method, contentType, headers, template } = notifyConfig['webhook'] + if (!url) return logger.info('未发送Webhook通知, URL为空') + await sendWebhook({ url, method, contentType, headers, template }, title, content) + break default: logger.info('未配置通知类型: ', type) break @@ -97,5 +235,7 @@ async function sendNoticeAsync(noticeAction, title, content) { module.exports = { sendNoticeAsync, sendServerChan, - sendEmail + sendEmail, + sendTg, + sendWebhook } \ No newline at end of file diff --git a/web/src/views/setting/components/notify.vue b/web/src/views/setting/components/notify.vue index 37c7a09..de2f57d 100644 --- a/web/src/views/setting/components/notify.vue +++ b/web/src/views/setting/components/notify.vue @@ -6,7 +6,7 @@ :inline="false" :hide-required-asterisk="true" :show-message="false" - label-width="100px" + label-width="120px" label-suffix=":" > @@ -85,6 +85,62 @@ Telegram Token/ChatId 获取: 查看教程 + + $store.isPlusActive) const handleSave = () => { formRef.value.validate(async (valid) => { if (!valid) return + + // Webhook 类型时校验 JSON 格式 + if (noticeConfig.value.type === 'webhook') { + const { template, headers } = noticeConfig.value.webhook || {} + + // 校验自定义模板 + if (template) { + try { + JSON.parse(template) + } catch (e) { + $message.error('自定义模板不是有效的 JSON 格式,请检查后重试') + return + } + } + + // 校验自定义请求头 + if (headers) { + try { + JSON.parse(headers) + } catch (e) { + $message.error('自定义请求头不是有效的 JSON 格式,请检查后重试') + return + } + } + } + try { loading.value = true await $api.updateNotifyConfig({ noticeConfig: { ...noticeConfig.value } }) @@ -211,6 +301,17 @@ const handleChangeSw = async (row) => { getNotifyList(false) } +const formatTemplate = () => { + if (!noticeConfig.value.webhook?.template) return + try { + const parsed = JSON.parse(noticeConfig.value.webhook.template) + noticeConfig.value.webhook.template = JSON.stringify(parsed, null, 2) + $message.success('格式化成功') + } catch (e) { + $message.warning('格式化失败:模板不是有效的 JSON 格式') + } +} + onMounted(() => { getNotifyList() getNotifyConfig() @@ -228,5 +329,13 @@ onMounted(() => { font-size: 14px; color: #999; } + .template-wrapper { + display: flex; + flex-direction: column; + gap: 8px; + .format-btn { + align-self: flex-start; + } + } }