mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-07 14:07:59 +08:00
feat: 支持webhook通知
This commit is contained in:
@@ -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: '测试通过 | 保存成功' })
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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 格式,可选。例如:{"Authorization": "Bearer xxx"}"
|
||||
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="{
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": "{{title}}\n{{content}}"
|
||||
}
|
||||
}"
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user