feat: 支持webhook通知

This commit is contained in:
chaos-zhu
2025-12-28 18:39:54 +08:00
parent 035e4ee85e
commit 3a8529b471
4 changed files with 269 additions and 15 deletions
+4 -6
View File
@@ -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: '测试通过 | 保存成功' })
+7
View File
@@ -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) {
+147 -7
View File
@@ -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
}
+111 -2
View File
@@ -6,7 +6,7 @@
:inline="false"
:hide-required-asterisk="true"
:show-message="false"
label-width="100px"
label-width="120px"
label-suffix=":"
>
<el-form-item label="通知方式" prop="type" class="form_item">
@@ -85,6 +85,62 @@
<span class="tips">Telegram Token/ChatId 获取: <a class="link" href="https://easynode.chaoszhu.com/zh/guide/get-tg-token" target="_blank">查看教程</a> </span>
</el-form-item>
</template>
<!-- Webhook -->
<template v-if="noticeConfig.type === 'webhook'">
<el-form-item label="URL" prop="webhook.url" class="form_item">
<el-input
v-model.trim="noticeConfig.webhook.url"
clearable
placeholder="https://example.com/webhook"
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="请求方法" class="form_item">
<el-select v-model="noticeConfig.webhook.method" placeholder="" class="input">
<el-option label="POST" value="POST" />
<el-option label="GET" value="GET" />
<el-option label="PUT" value="PUT" />
</el-select>
</el-form-item>
<el-form-item label="Content-Type" class="form_item">
<el-select v-model="noticeConfig.webhook.contentType" placeholder="" class="input">
<el-option label="application/json" value="application/json" />
<el-option label="application/x-www-form-urlencoded" value="application/x-www-form-urlencoded" />
<el-option label="multipart/form-data" value="multipart/form-data" />
<el-option label="text/plain" value="text/plain" />
</el-select>
</el-form-item>
<el-form-item label="Header" class="form_item">
<el-input
v-model="noticeConfig.webhook.headers"
type="textarea"
:rows="2"
placeholder="JSON 格式,可选。例如:{&quot;Authorization&quot;: &quot;Bearer xxx&quot;}"
autocomplete="off"
class="input"
/>
</el-form-item>
<el-form-item label="自定义模板" class="form_item">
<div class="template-wrapper">
<el-input
v-model="noticeConfig.webhook.template"
type="textarea"
:rows="6"
placeholder="{
&quot;msgtype&quot;: &quot;text&quot;,
&quot;text&quot;: {
&quot;content&quot;: &quot;{{title}}\n{{content}}&quot;
}
}"
autocomplete="off"
class="input"
/>
<el-button size="small" class="format-btn" @click="formatTemplate">格式化</el-button>
</div>
<span class="tips">支持变量: <code v-pre>{{title}}</code>, <code v-pre>{{content}}</code>, <code v-pre>{{timestamp}}</code>, <code v-pre>{{datetime}}</code>。留空则使用默认格式</span>
</el-form-item>
</template>
<el-form-item label="" class="form_item">
<el-button
type="primary"
@@ -120,7 +176,7 @@
import { ref, onMounted, computed, reactive } from 'vue'
import { getCurrentInstance } from 'vue'
const { proxy: { $api, $store, $notification } } = getCurrentInstance()
const { proxy: { $api, $store, $notification, $message } } = getCurrentInstance()
const notifyListLoading = ref(false)
const notifyList = ref([])
@@ -139,6 +195,10 @@ const noticeTypeList = ref([
type: 'tg',
desc: 'Telegram'
},
{
type: 'webhook',
desc: 'Webhook'
},
])
const formRef = ref(null)
const rules = reactive({
@@ -154,6 +214,10 @@ const rules = reactive({
message: 'ChatId必须为数字',
trigger: ['blur', 'change',]
},
],
'webhook.url': [
{ required: true, message: '需输入Webhook URL', trigger: 'change' },
{ type: 'url', message: '请输入有效的URL', trigger: 'change' },
]
})
@@ -162,6 +226,32 @@ const isPlusActive = computed(() => $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;
}
}
}
</style>