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 获取: 查看教程
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 格式化
+
+ 支持变量: {{title}}, {{content}}, {{timestamp}}, {{datetime}}。留空则使用默认格式
+
+
$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;
+ }
+ }
}