const axios = require('axios') const crypto = require('crypto') const nodemailer = require('nodemailer') const { HttpsProxyAgent } = require('https-proxy-agent') const { SocksProxyAgent } = require('socks-proxy-agent') const logger = require('../utils/logger') const webhookConfigService = require('./webhookConfigService') const { getISOStringWithTimezone } = require('../utils/dateHelper') const appConfig = require('../../config/config') class WebhookService { constructor() { this.platformHandlers = { wechat_work: this.sendToWechatWork.bind(this), dingtalk: this.sendToDingTalk.bind(this), feishu: this.sendToFeishu.bind(this), slack: this.sendToSlack.bind(this), discord: this.sendToDiscord.bind(this), telegram: this.sendToTelegram.bind(this), custom: this.sendToCustom.bind(this), bark: this.sendToBark.bind(this), smtp: this.sendToSMTP.bind(this) } this.timezone = appConfig.system.timezone || 'Asia/Shanghai' } /** * 发送通知到所有启用的平台 */ async sendNotification(type, data) { try { const config = await webhookConfigService.getConfig() // 检查是否启用webhook if (!config.enabled) { logger.debug('Webhook通知已禁用') return } // 检查通知类型是否启用(test类型始终允许发送) if (type !== 'test' && config.notificationTypes && !config.notificationTypes[type]) { logger.debug(`通知类型 ${type} 已禁用`) return } // 获取启用的平台 const enabledPlatforms = await webhookConfigService.getEnabledPlatforms() if (enabledPlatforms.length === 0) { logger.debug('没有启用的webhook平台') return } logger.info(`📢 发送 ${type} 通知到 ${enabledPlatforms.length} 个平台`) // 并发发送到所有平台 const promises = enabledPlatforms.map((platform) => this.sendToPlatform(platform, type, data, config.retrySettings) ) const results = await Promise.allSettled(promises) // 记录结果 const succeeded = results.filter((r) => r.status === 'fulfilled').length const failed = results.filter((r) => r.status === 'rejected').length if (failed > 0) { logger.warn(`⚠️ Webhook通知: ${succeeded}成功, ${failed}失败`) } else { logger.info(`✅ 所有webhook通知发送成功`) } return { succeeded, failed } } catch (error) { logger.error('发送webhook通知失败:', error) throw error } } /** * 发送到特定平台 */ async sendToPlatform(platform, type, data, retrySettings) { try { const handler = this.platformHandlers[platform.type] if (!handler) { throw new Error(`不支持的平台类型: ${platform.type}`) } // 使用平台特定的处理器 await this.retryWithBackoff( () => handler(platform, type, data), retrySettings?.maxRetries || 3, retrySettings?.retryDelay || 1000 ) logger.info(`✅ 成功发送到 ${platform.name || platform.type}`) } catch (error) { logger.error(`❌ 发送到 ${platform.name || platform.type} 失败:`, error.message) throw error } } /** * 企业微信webhook */ async sendToWechatWork(platform, type, data) { const content = this.formatMessageForWechatWork(type, data) const payload = { msgtype: 'markdown', markdown: { content } } await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) } /** * 钉钉webhook */ async sendToDingTalk(platform, type, data) { const content = this.formatMessageForDingTalk(type, data) let { url } = platform const payload = { msgtype: 'markdown', markdown: { title: this.getNotificationTitle(type), text: content } } // 如果启用签名 if (platform.enableSign && platform.secret) { const timestamp = Date.now() const sign = this.generateDingTalkSign(platform.secret, timestamp) url = `${url}×tamp=${timestamp}&sign=${encodeURIComponent(sign)}` } await this.sendHttpRequest(url, payload, platform.timeout || 10000) } /** * 飞书webhook */ async sendToFeishu(platform, type, data) { const content = this.formatMessageForFeishu(type, data) const payload = { msg_type: 'interactive', card: { elements: [ { tag: 'markdown', content } ], header: { title: { tag: 'plain_text', content: this.getNotificationTitle(type) }, template: this.getFeishuCardColor(type) } } } // 如果启用签名 if (platform.enableSign && platform.secret) { const timestamp = Math.floor(Date.now() / 1000) const sign = this.generateFeishuSign(platform.secret, timestamp) payload.timestamp = timestamp.toString() payload.sign = sign } await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) } /** * Slack webhook */ async sendToSlack(platform, type, data) { const text = this.formatMessageForSlack(type, data) const payload = { text, username: 'Claude Relay Service', icon_emoji: this.getSlackEmoji(type) } await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) } /** * Discord webhook */ async sendToDiscord(platform, type, data) { const embed = this.formatMessageForDiscord(type, data) const payload = { username: 'Claude Relay Service', embeds: [embed] } await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) } /** * 自定义webhook */ async sendToCustom(platform, type, data) { // 使用通用格式 const payload = { type, service: 'claude-relay-service', timestamp: getISOStringWithTimezone(new Date()), data } await this.sendHttpRequest(platform.url, payload, platform.timeout || 10000) } /** * Telegram Bot 通知 */ async sendToTelegram(platform, type, data) { if (!platform.botToken) { throw new Error('缺少 Telegram 机器人 Token') } if (!platform.chatId) { throw new Error('缺少 Telegram Chat ID') } const baseUrl = this.normalizeTelegramApiBase(platform.apiBaseUrl) const apiUrl = `${baseUrl}/bot${platform.botToken}/sendMessage` const payload = { chat_id: platform.chatId, text: this.formatMessageForTelegram(type, data), disable_web_page_preview: true } const axiosOptions = this.buildTelegramAxiosOptions(platform) const response = await this.sendHttpRequest( apiUrl, payload, platform.timeout || 10000, axiosOptions ) if (!response || response.ok !== true) { throw new Error(`Telegram API 错误: ${response?.description || '未知错误'}`) } } /** * Bark webhook */ async sendToBark(platform, type, data) { const payload = { device_key: platform.deviceKey, title: this.getNotificationTitle(type), body: this.formatMessageForBark(type, data), level: platform.level || this.getBarkLevel(type), sound: platform.sound || this.getBarkSound(type), group: platform.group || 'claude-relay', badge: 1 } // 添加可选参数 if (platform.icon) { payload.icon = platform.icon } if (platform.clickUrl) { payload.url = platform.clickUrl } const url = platform.serverUrl || 'https://api.day.app/push' await this.sendHttpRequest(url, payload, platform.timeout || 10000) } /** * SMTP邮件通知 */ async sendToSMTP(platform, type, data) { try { // 创建SMTP传输器 const transporter = nodemailer.createTransport({ host: platform.host, port: platform.port || 587, secure: platform.secure || false, // true for 465, false for other ports auth: { user: platform.user, pass: platform.pass }, // 可选的TLS配置 tls: platform.ignoreTLS ? { rejectUnauthorized: false } : undefined, // 连接超时 connectionTimeout: platform.timeout || 10000 }) // 构造邮件内容 const subject = this.getNotificationTitle(type) const htmlContent = this.formatMessageForEmail(type, data) const textContent = this.formatMessageForEmailText(type, data) // 邮件选项 const mailOptions = { from: platform.from || platform.user, // 发送者 to: platform.to, // 接收者(必填) subject: `[Claude Relay Service] ${subject}`, text: textContent, html: htmlContent } // 发送邮件 const info = await transporter.sendMail(mailOptions) logger.info(`✅ 邮件发送成功: ${info.messageId}`) return info } catch (error) { logger.error('SMTP邮件发送失败:', error) throw error } } /** * 发送HTTP请求 */ async sendHttpRequest(url, payload, timeout, axiosOptions = {}) { const headers = { 'Content-Type': 'application/json', 'User-Agent': 'claude-relay-service/2.0', ...(axiosOptions.headers || {}) } const response = await axios.post(url, payload, { timeout, ...axiosOptions, headers }) if (response.status < 200 || response.status >= 300) { throw new Error(`HTTP ${response.status}: ${response.statusText}`) } return response.data } /** * 重试机制 */ async retryWithBackoff(fn, maxRetries, baseDelay) { let lastError for (let i = 0; i < maxRetries; i++) { try { return await fn() } catch (error) { lastError = error if (i < maxRetries - 1) { const delay = baseDelay * Math.pow(2, i) // 指数退避 logger.debug(`🔄 重试 ${i + 1}/${maxRetries},等待 ${delay}ms`) await new Promise((resolve) => setTimeout(resolve, delay)) } } } throw lastError } /** * 生成钉钉签名 */ generateDingTalkSign(secret, timestamp) { const stringToSign = `${timestamp}\n${secret}` const hmac = crypto.createHmac('sha256', secret) hmac.update(stringToSign) return hmac.digest('base64') } /** * 生成飞书签名 */ generateFeishuSign(secret, timestamp) { const stringToSign = `${timestamp}\n${secret}` const hmac = crypto.createHmac('sha256', stringToSign) hmac.update('') return hmac.digest('base64') } /** * 格式化企业微信消息 */ formatMessageForWechatWork(type, data) { const title = this.getNotificationTitle(type) const details = this.formatNotificationDetails(data) return ( `## ${title}\n\n` + `> **服务**: Claude Relay Service\n` + `> **时间**: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` ) } /** * 格式化钉钉消息 */ formatMessageForDingTalk(type, data) { const details = this.formatNotificationDetails(data) return ( `#### 服务: Claude Relay Service\n` + `#### 时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}\n\n${details}` ) } /** * 格式化飞书消息 */ formatMessageForFeishu(type, data) { return this.formatNotificationDetails(data) } /** * 格式化Slack消息 */ formatMessageForSlack(type, data) { const title = this.getNotificationTitle(type) const details = this.formatNotificationDetails(data) return `*${title}*\n${details}` } /** * 规范化Telegram基础地址 */ normalizeTelegramApiBase(baseUrl) { const defaultBase = 'https://api.telegram.org' if (!baseUrl) { return defaultBase } try { const parsed = new URL(baseUrl) if (!['http:', 'https:'].includes(parsed.protocol)) { throw new Error('Telegram API 基础地址必须使用 http 或 https 协议') } // 移除结尾的 / return parsed.href.replace(/\/$/, '') } catch (error) { logger.warn(`⚠️ Telegram API 基础地址无效,将使用默认值: ${error.message}`) return defaultBase } } /** * 构建 Telegram 请求的 axios 选项(代理等) */ buildTelegramAxiosOptions(platform) { const options = {} if (platform.proxyUrl) { try { const proxyUrl = new URL(platform.proxyUrl) const { protocol } = proxyUrl if (protocol.startsWith('socks')) { const agent = new SocksProxyAgent(proxyUrl.toString()) options.httpAgent = agent options.httpsAgent = agent options.proxy = false } else if (protocol === 'http:' || protocol === 'https:') { const agent = new HttpsProxyAgent(proxyUrl.toString()) options.httpAgent = agent options.httpsAgent = agent options.proxy = false } else { logger.warn(`⚠️ 不支持的Telegram代理协议: ${protocol}`) } } catch (error) { logger.warn(`⚠️ Telegram代理配置无效,将忽略: ${error.message}`) } } return options } /** * 格式化 Telegram 消息 */ formatMessageForTelegram(type, data) { const title = this.getNotificationTitle(type) const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) const details = this.buildNotificationDetails(data) const lines = [`${title}`, '服务: Claude Relay Service'] if (details.length > 0) { lines.push('') for (const detail of details) { lines.push(`${detail.label}: ${detail.value}`) } } lines.push('', `时间: ${timestamp}`) return lines.join('\n') } /** * 格式化Discord消息 */ formatMessageForDiscord(type, data) { const title = this.getNotificationTitle(type) const color = this.getDiscordColor(type) const fields = this.formatNotificationFields(data) return { title, color, fields, timestamp: getISOStringWithTimezone(new Date()), footer: { text: 'Claude Relay Service' } } } /** * 获取通知标题 */ getNotificationTitle(type) { const titles = { accountAnomaly: '⚠️ 账号异常通知', quotaWarning: '📊 配额警告', systemError: '❌ 系统错误', securityAlert: '🔒 安全警报', rateLimitRecovery: '🎉 限流恢复通知', test: '🧪 测试通知' } return titles[type] || '📢 系统通知' } /** * 获取Bark通知级别 */ getBarkLevel(type) { const levels = { accountAnomaly: 'timeSensitive', quotaWarning: 'active', systemError: 'critical', securityAlert: 'critical', rateLimitRecovery: 'active', test: 'passive' } return levels[type] || 'active' } /** * 获取Bark声音 */ getBarkSound(type) { const sounds = { accountAnomaly: 'alarm', quotaWarning: 'bell', systemError: 'alert', securityAlert: 'alarm', rateLimitRecovery: 'success', test: 'default' } return sounds[type] || 'default' } /** * 格式化Bark消息 */ formatMessageForBark(type, data) { const lines = [] if (data.accountName) { lines.push(`账号: ${data.accountName}`) } if (data.platform) { lines.push(`平台: ${data.platform}`) } if (data.status) { lines.push(`状态: ${data.status}`) } if (data.errorCode) { lines.push(`错误: ${data.errorCode}`) } if (data.reason) { lines.push(`原因: ${data.reason}`) } if (data.message) { lines.push(`消息: ${data.message}`) } if (data.quota) { lines.push(`剩余配额: ${data.quota.remaining}/${data.quota.total}`) } if (data.usage) { lines.push(`使用率: ${data.usage}%`) } // 添加服务标识和时间戳 lines.push(`\n服务: Claude Relay Service`) lines.push(`时间: ${new Date().toLocaleString('zh-CN', { timeZone: this.timezone })}`) return lines.join('\n') } /** * 构建通知详情数据 */ buildNotificationDetails(data) { const details = [] if (data.accountName) { details.push({ label: '账号', value: data.accountName }) } if (data.platform) { details.push({ label: '平台', value: data.platform }) } if (data.status) { details.push({ label: '状态', value: data.status, color: this.getStatusColor(data.status) }) } if (data.errorCode) { details.push({ label: '错误代码', value: data.errorCode, isCode: true }) } if (data.reason) { details.push({ label: '原因', value: data.reason }) } if (data.message) { details.push({ label: '消息', value: data.message }) } if (data.quota) { details.push({ label: '配额', value: `${data.quota.remaining}/${data.quota.total}` }) } if (data.usage) { details.push({ label: '使用率', value: `${data.usage}%` }) } return details } /** * 格式化邮件HTML内容 */ formatMessageForEmail(type, data) { const title = this.getNotificationTitle(type) const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) const details = this.buildNotificationDetails(data) let content = `

${title}

Claude Relay Service

` // 使用统一的详情数据渲染 details.forEach((detail) => { if (detail.isCode) { content += `

${detail.label}: ${detail.value}

` } else if (detail.color) { content += `

${detail.label}: ${detail.value}

` } else { content += `

${detail.label}: ${detail.value}

` } }) content += `

发送时间: ${timestamp}

此邮件由 Claude Relay Service 自动发送

` return content } /** * 格式化邮件纯文本内容 */ formatMessageForEmailText(type, data) { const title = this.getNotificationTitle(type) const timestamp = new Date().toLocaleString('zh-CN', { timeZone: this.timezone }) const details = this.buildNotificationDetails(data) let content = `${title}\n` content += `=====================================\n\n` // 使用统一的详情数据渲染 details.forEach((detail) => { content += `${detail.label}: ${detail.value}\n` }) content += `\n发送时间: ${timestamp}\n` content += `服务: Claude Relay Service\n` content += `=====================================\n` content += `此邮件由系统自动发送,请勿回复。` return content } /** * 获取状态颜色 */ getStatusColor(status) { const colors = { error: '#dc3545', unauthorized: '#fd7e14', blocked: '#6f42c1', disabled: '#6c757d', active: '#28a745', warning: '#ffc107' } return colors[status] || '#007bff' } /** * 格式化通知详情 */ formatNotificationDetails(data) { const lines = [] if (data.accountName) { lines.push(`**账号**: ${data.accountName}`) } if (data.platform) { lines.push(`**平台**: ${data.platform}`) } if (data.platforms) { lines.push(`**涉及平台**: ${data.platforms.join(', ')}`) } if (data.totalAccounts) { lines.push(`**恢复账户数**: ${data.totalAccounts}`) } if (data.status) { lines.push(`**状态**: ${data.status}`) } if (data.errorCode) { lines.push(`**错误代码**: ${data.errorCode}`) } if (data.reason) { lines.push(`**原因**: ${data.reason}`) } if (data.message) { lines.push(`**消息**: ${data.message}`) } if (data.quota) { lines.push(`**剩余配额**: ${data.quota.remaining}/${data.quota.total}`) } if (data.usage) { lines.push(`**使用率**: ${data.usage}%`) } return lines.join('\n') } /** * 格式化Discord字段 */ formatNotificationFields(data) { const fields = [] if (data.accountName) { fields.push({ name: '账号', value: data.accountName, inline: true }) } if (data.platform) { fields.push({ name: '平台', value: data.platform, inline: true }) } if (data.status) { fields.push({ name: '状态', value: data.status, inline: true }) } if (data.errorCode) { fields.push({ name: '错误代码', value: data.errorCode, inline: false }) } if (data.reason) { fields.push({ name: '原因', value: data.reason, inline: false }) } if (data.message) { fields.push({ name: '消息', value: data.message, inline: false }) } return fields } /** * 获取飞书卡片颜色 */ getFeishuCardColor(type) { const colors = { accountAnomaly: 'orange', quotaWarning: 'yellow', systemError: 'red', securityAlert: 'red', rateLimitRecovery: 'green', test: 'blue' } return colors[type] || 'blue' } /** * 获取Slack emoji */ getSlackEmoji(type) { const emojis = { accountAnomaly: ':warning:', quotaWarning: ':chart_with_downwards_trend:', systemError: ':x:', securityAlert: ':lock:', rateLimitRecovery: ':tada:', test: ':test_tube:' } return emojis[type] || ':bell:' } /** * 获取Discord颜色 */ getDiscordColor(type) { const colors = { accountAnomaly: 0xff9800, // 橙色 quotaWarning: 0xffeb3b, // 黄色 systemError: 0xf44336, // 红色 securityAlert: 0xf44336, // 红色 rateLimitRecovery: 0x4caf50, // 绿色 test: 0x2196f3 // 蓝色 } return colors[type] || 0x9e9e9e // 灰色 } /** * 测试webhook连接 */ async testWebhook(platform) { try { const testData = { message: 'Claude Relay Service webhook测试', timestamp: getISOStringWithTimezone(new Date()) } await this.sendToPlatform(platform, 'test', testData, { maxRetries: 1, retryDelay: 1000 }) return { success: true } } catch (error) { return { success: false, error: error.message } } } } module.exports = new WebhookService()