| | 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() |
| |
|