|
|
const redis = require('../models/redis') |
|
|
const logger = require('../utils/logger') |
|
|
const { v4: uuidv4 } = require('uuid') |
|
|
|
|
|
class WebhookConfigService { |
|
|
constructor() { |
|
|
this.KEY_PREFIX = 'webhook_config' |
|
|
this.DEFAULT_CONFIG_KEY = `${this.KEY_PREFIX}:default` |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getConfig() { |
|
|
try { |
|
|
const configStr = await redis.client.get(this.DEFAULT_CONFIG_KEY) |
|
|
if (!configStr) { |
|
|
|
|
|
return this.getDefaultConfig() |
|
|
} |
|
|
|
|
|
const storedConfig = JSON.parse(configStr) |
|
|
const defaultConfig = this.getDefaultConfig() |
|
|
|
|
|
|
|
|
storedConfig.notificationTypes = { |
|
|
...defaultConfig.notificationTypes, |
|
|
...(storedConfig.notificationTypes || {}) |
|
|
} |
|
|
|
|
|
return storedConfig |
|
|
} catch (error) { |
|
|
logger.error('获取webhook配置失败:', error) |
|
|
return this.getDefaultConfig() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async saveConfig(config) { |
|
|
try { |
|
|
const defaultConfig = this.getDefaultConfig() |
|
|
|
|
|
config.notificationTypes = { |
|
|
...defaultConfig.notificationTypes, |
|
|
...(config.notificationTypes || {}) |
|
|
} |
|
|
|
|
|
|
|
|
this.validateConfig(config) |
|
|
|
|
|
|
|
|
config.updatedAt = new Date().toISOString() |
|
|
|
|
|
await redis.client.set(this.DEFAULT_CONFIG_KEY, JSON.stringify(config)) |
|
|
logger.info('✅ Webhook配置已保存') |
|
|
|
|
|
return config |
|
|
} catch (error) { |
|
|
logger.error('保存webhook配置失败:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
validateConfig(config) { |
|
|
if (!config || typeof config !== 'object') { |
|
|
throw new Error('无效的配置格式') |
|
|
} |
|
|
|
|
|
|
|
|
if (config.platforms) { |
|
|
const validPlatforms = [ |
|
|
'wechat_work', |
|
|
'dingtalk', |
|
|
'feishu', |
|
|
'slack', |
|
|
'discord', |
|
|
'telegram', |
|
|
'custom', |
|
|
'bark', |
|
|
'smtp' |
|
|
] |
|
|
|
|
|
for (const platform of config.platforms) { |
|
|
if (!validPlatforms.includes(platform.type)) { |
|
|
throw new Error(`不支持的平台类型: ${platform.type}`) |
|
|
} |
|
|
|
|
|
|
|
|
if (!['bark', 'smtp', 'telegram'].includes(platform.type)) { |
|
|
if (!platform.url || !this.isValidUrl(platform.url)) { |
|
|
throw new Error(`无效的webhook URL: ${platform.url}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
this.validatePlatformConfig(platform) |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
validatePlatformConfig(platform) { |
|
|
switch (platform.type) { |
|
|
case 'wechat_work': |
|
|
|
|
|
break |
|
|
case 'dingtalk': |
|
|
|
|
|
if (platform.enableSign && !platform.secret) { |
|
|
throw new Error('钉钉启用签名时必须提供secret') |
|
|
} |
|
|
break |
|
|
case 'feishu': |
|
|
|
|
|
if (platform.enableSign && !platform.secret) { |
|
|
throw new Error('飞书启用签名时必须提供secret') |
|
|
} |
|
|
break |
|
|
case 'slack': |
|
|
|
|
|
if (!platform.url.includes('hooks.slack.com')) { |
|
|
logger.warn('⚠️ Slack webhook URL格式可能不正确') |
|
|
} |
|
|
break |
|
|
case 'discord': |
|
|
|
|
|
if (!platform.url.includes('discord.com/api/webhooks')) { |
|
|
logger.warn('⚠️ Discord webhook URL格式可能不正确') |
|
|
} |
|
|
break |
|
|
case 'telegram': |
|
|
if (!platform.botToken) { |
|
|
throw new Error('Telegram 平台必须提供机器人 Token') |
|
|
} |
|
|
if (!platform.chatId) { |
|
|
throw new Error('Telegram 平台必须提供 Chat ID') |
|
|
} |
|
|
|
|
|
if (!platform.botToken.includes(':')) { |
|
|
logger.warn('⚠️ Telegram 机器人 Token 格式可能不正确') |
|
|
} |
|
|
|
|
|
if (!/^[-\d]+$/.test(String(platform.chatId))) { |
|
|
logger.warn('⚠️ Telegram Chat ID 应该是数字,如为频道请确认已获取正确ID') |
|
|
} |
|
|
|
|
|
if (platform.apiBaseUrl) { |
|
|
if (!this.isValidUrl(platform.apiBaseUrl)) { |
|
|
throw new Error('Telegram API 基础地址格式无效') |
|
|
} |
|
|
const { protocol } = new URL(platform.apiBaseUrl) |
|
|
if (!['http:', 'https:'].includes(protocol)) { |
|
|
throw new Error('Telegram API 基础地址仅支持 http 或 https 协议') |
|
|
} |
|
|
} |
|
|
|
|
|
if (platform.proxyUrl) { |
|
|
if (!this.isValidUrl(platform.proxyUrl)) { |
|
|
throw new Error('Telegram 代理地址格式无效') |
|
|
} |
|
|
const proxyProtocol = new URL(platform.proxyUrl).protocol |
|
|
const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:'] |
|
|
if (!supportedProtocols.includes(proxyProtocol)) { |
|
|
throw new Error('Telegram 代理仅支持 http/https/socks 协议') |
|
|
} |
|
|
} |
|
|
break |
|
|
case 'custom': |
|
|
|
|
|
break |
|
|
case 'bark': |
|
|
|
|
|
if (!platform.deviceKey) { |
|
|
throw new Error('Bark平台必须提供设备密钥') |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.deviceKey.length < 20 || platform.deviceKey.length > 30) { |
|
|
logger.warn('⚠️ Bark设备密钥长度可能不正确,请检查是否完整复制') |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.serverUrl) { |
|
|
if (!this.isValidUrl(platform.serverUrl)) { |
|
|
throw new Error('Bark服务器URL格式无效') |
|
|
} |
|
|
if (!platform.serverUrl.includes('/push')) { |
|
|
logger.warn('⚠️ Bark服务器URL应该以/push结尾') |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.sound) { |
|
|
const validSounds = [ |
|
|
'default', |
|
|
'alarm', |
|
|
'anticipate', |
|
|
'bell', |
|
|
'birdsong', |
|
|
'bloom', |
|
|
'calypso', |
|
|
'chime', |
|
|
'choo', |
|
|
'descent', |
|
|
'electronic', |
|
|
'fanfare', |
|
|
'glass', |
|
|
'gotosleep', |
|
|
'healthnotification', |
|
|
'horn', |
|
|
'ladder', |
|
|
'mailsent', |
|
|
'minuet', |
|
|
'multiwayinvitation', |
|
|
'newmail', |
|
|
'newsflash', |
|
|
'noir', |
|
|
'paymentsuccess', |
|
|
'shake', |
|
|
'sherwoodforest', |
|
|
'silence', |
|
|
'spell', |
|
|
'suspense', |
|
|
'telegraph', |
|
|
'tiptoes', |
|
|
'typewriters', |
|
|
'update', |
|
|
'alert' |
|
|
] |
|
|
if (!validSounds.includes(platform.sound)) { |
|
|
logger.warn(`⚠️ 未知的Bark声音: ${platform.sound}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.level) { |
|
|
const validLevels = ['active', 'timeSensitive', 'passive', 'critical'] |
|
|
if (!validLevels.includes(platform.level)) { |
|
|
throw new Error(`无效的Bark通知级别: ${platform.level}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.icon && !this.isValidUrl(platform.icon)) { |
|
|
logger.warn('⚠️ Bark图标URL格式可能不正确') |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.clickUrl && !this.isValidUrl(platform.clickUrl)) { |
|
|
logger.warn('⚠️ Bark点击跳转URL格式可能不正确') |
|
|
} |
|
|
break |
|
|
case 'smtp': { |
|
|
|
|
|
if (!platform.host) { |
|
|
throw new Error('SMTP平台必须提供主机地址') |
|
|
} |
|
|
if (!platform.user) { |
|
|
throw new Error('SMTP平台必须提供用户名') |
|
|
} |
|
|
if (!platform.pass) { |
|
|
throw new Error('SMTP平台必须提供密码') |
|
|
} |
|
|
if (!platform.to) { |
|
|
throw new Error('SMTP平台必须提供接收邮箱') |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.port && (platform.port < 1 || platform.port > 65535)) { |
|
|
throw new Error('SMTP端口必须在1-65535之间') |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const simpleEmailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ |
|
|
|
|
|
|
|
|
const toEmails = Array.isArray(platform.to) ? platform.to : [platform.to] |
|
|
for (const email of toEmails) { |
|
|
|
|
|
const actualEmail = email.includes('<') ? email.match(/<([^>]+)>/)?.[1] : email |
|
|
if (!actualEmail || !simpleEmailRegex.test(actualEmail)) { |
|
|
throw new Error(`无效的接收邮箱格式: ${email}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
if (platform.from) { |
|
|
const actualFromEmail = platform.from.includes('<') |
|
|
? platform.from.match(/<([^>]+)>/)?.[1] |
|
|
: platform.from |
|
|
if (!actualFromEmail || !simpleEmailRegex.test(actualFromEmail)) { |
|
|
throw new Error(`无效的发送邮箱格式: ${platform.from}`) |
|
|
} |
|
|
} |
|
|
break |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
isValidUrl(url) { |
|
|
try { |
|
|
new URL(url) |
|
|
return true |
|
|
} catch { |
|
|
return false |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getDefaultConfig() { |
|
|
return { |
|
|
enabled: false, |
|
|
platforms: [], |
|
|
notificationTypes: { |
|
|
accountAnomaly: true, |
|
|
quotaWarning: true, |
|
|
systemError: true, |
|
|
securityAlert: true, |
|
|
rateLimitRecovery: true, |
|
|
test: true |
|
|
}, |
|
|
retrySettings: { |
|
|
maxRetries: 3, |
|
|
retryDelay: 1000, |
|
|
timeout: 10000 |
|
|
}, |
|
|
createdAt: new Date().toISOString(), |
|
|
updatedAt: new Date().toISOString() |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async addPlatform(platform) { |
|
|
try { |
|
|
const config = await this.getConfig() |
|
|
|
|
|
|
|
|
platform.id = platform.id || uuidv4() |
|
|
platform.enabled = platform.enabled !== false |
|
|
platform.createdAt = new Date().toISOString() |
|
|
|
|
|
|
|
|
this.validatePlatformConfig(platform) |
|
|
|
|
|
|
|
|
config.platforms = config.platforms || [] |
|
|
config.platforms.push(platform) |
|
|
|
|
|
await this.saveConfig(config) |
|
|
|
|
|
return platform |
|
|
} catch (error) { |
|
|
logger.error('添加webhook平台失败:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async updatePlatform(platformId, updates) { |
|
|
try { |
|
|
const config = await this.getConfig() |
|
|
|
|
|
const index = config.platforms.findIndex((p) => p.id === platformId) |
|
|
if (index === -1) { |
|
|
throw new Error('找不到指定的webhook平台') |
|
|
} |
|
|
|
|
|
|
|
|
config.platforms[index] = { |
|
|
...config.platforms[index], |
|
|
...updates, |
|
|
updatedAt: new Date().toISOString() |
|
|
} |
|
|
|
|
|
|
|
|
this.validatePlatformConfig(config.platforms[index]) |
|
|
|
|
|
await this.saveConfig(config) |
|
|
|
|
|
return config.platforms[index] |
|
|
} catch (error) { |
|
|
logger.error('更新webhook平台失败:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async deletePlatform(platformId) { |
|
|
try { |
|
|
const config = await this.getConfig() |
|
|
|
|
|
config.platforms = config.platforms.filter((p) => p.id !== platformId) |
|
|
|
|
|
await this.saveConfig(config) |
|
|
|
|
|
logger.info(`✅ 已删除webhook平台: ${platformId}`) |
|
|
return true |
|
|
} catch (error) { |
|
|
logger.error('删除webhook平台失败:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async togglePlatform(platformId) { |
|
|
try { |
|
|
const config = await this.getConfig() |
|
|
|
|
|
const platform = config.platforms.find((p) => p.id === platformId) |
|
|
if (!platform) { |
|
|
throw new Error('找不到指定的webhook平台') |
|
|
} |
|
|
|
|
|
platform.enabled = !platform.enabled |
|
|
platform.updatedAt = new Date().toISOString() |
|
|
|
|
|
await this.saveConfig(config) |
|
|
|
|
|
logger.info(`✅ Webhook平台 ${platformId} 已${platform.enabled ? '启用' : '禁用'}`) |
|
|
return platform |
|
|
} catch (error) { |
|
|
logger.error('切换webhook平台状态失败:', error) |
|
|
throw error |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async getEnabledPlatforms() { |
|
|
try { |
|
|
const config = await this.getConfig() |
|
|
|
|
|
if (!config.enabled || !config.platforms) { |
|
|
return [] |
|
|
} |
|
|
|
|
|
return config.platforms.filter((p) => p.enabled) |
|
|
} catch (error) { |
|
|
logger.error('获取启用的webhook平台失败:', error) |
|
|
return [] |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
module.exports = new WebhookConfigService() |
|
|
|