|
|
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() |
|
|
|
|
|
|
|
|
if (!config.enabled) { |
|
|
logger.debug('Webhook通知已禁用') |
|
|
return |
|
|
} |
|
|
|
|
|
|
|
|
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 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 || '未知错误'}`) |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async sendToSMTP(platform, type, data) { |
|
|
try { |
|
|
|
|
|
const transporter = nodemailer.createTransport({ |
|
|
host: platform.host, |
|
|
port: platform.port || 587, |
|
|
secure: platform.secure || false, |
|
|
auth: { |
|
|
user: platform.user, |
|
|
pass: platform.pass |
|
|
}, |
|
|
|
|
|
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 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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) |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
formatMessageForSlack(type, data) { |
|
|
const title = this.getNotificationTitle(type) |
|
|
const details = this.formatNotificationDetails(data) |
|
|
|
|
|
return `*${title}*\n${details}` |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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') |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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] || '📢 系统通知' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getBarkLevel(type) { |
|
|
const levels = { |
|
|
accountAnomaly: 'timeSensitive', |
|
|
quotaWarning: 'active', |
|
|
systemError: 'critical', |
|
|
securityAlert: 'critical', |
|
|
rateLimitRecovery: 'active', |
|
|
test: 'passive' |
|
|
} |
|
|
|
|
|
return levels[type] || 'active' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getBarkSound(type) { |
|
|
const sounds = { |
|
|
accountAnomaly: 'alarm', |
|
|
quotaWarning: 'bell', |
|
|
systemError: 'alert', |
|
|
securityAlert: 'alarm', |
|
|
rateLimitRecovery: 'success', |
|
|
test: 'default' |
|
|
} |
|
|
|
|
|
return sounds[type] || 'default' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = ` |
|
|
<div style="max-width: 600px; margin: 0 auto; font-family: Arial, sans-serif;"> |
|
|
<div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 20px; border-radius: 8px 8px 0 0;"> |
|
|
<h1 style="margin: 0; font-size: 24px;">${title}</h1> |
|
|
<p style="margin: 10px 0 0 0; opacity: 0.9;">Claude Relay Service</p> |
|
|
</div> |
|
|
<div style="background: #f8f9fa; padding: 20px; border: 1px solid #e9ecef; border-top: none; border-radius: 0 0 8px 8px;"> |
|
|
<div style="background: white; padding: 16px; border-radius: 6px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"> |
|
|
` |
|
|
|
|
|
|
|
|
details.forEach((detail) => { |
|
|
if (detail.isCode) { |
|
|
content += `<p><strong>${detail.label}:</strong> <code style="background: #f1f3f4; padding: 2px 6px; border-radius: 4px;">${detail.value}</code></p>` |
|
|
} else if (detail.color) { |
|
|
content += `<p><strong>${detail.label}:</strong> <span style="color: ${detail.color};">${detail.value}</span></p>` |
|
|
} else { |
|
|
content += `<p><strong>${detail.label}:</strong> ${detail.value}</p>` |
|
|
} |
|
|
}) |
|
|
|
|
|
content += ` |
|
|
</div> |
|
|
<div style="margin-top: 20px; padding-top: 16px; border-top: 1px solid #e9ecef; font-size: 14px; color: #6c757d; text-align: center;"> |
|
|
<p>发送时间: ${timestamp}</p> |
|
|
<p style="margin: 0;">此邮件由 Claude Relay Service 自动发送</p> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
` |
|
|
|
|
|
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') |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getSlackEmoji(type) { |
|
|
const emojis = { |
|
|
accountAnomaly: ':warning:', |
|
|
quotaWarning: ':chart_with_downwards_trend:', |
|
|
systemError: ':x:', |
|
|
securityAlert: ':lock:', |
|
|
rateLimitRecovery: ':tada:', |
|
|
test: ':test_tube:' |
|
|
} |
|
|
|
|
|
return emojis[type] || ':bell:' |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
getDiscordColor(type) { |
|
|
const colors = { |
|
|
accountAnomaly: 0xff9800, |
|
|
quotaWarning: 0xffeb3b, |
|
|
systemError: 0xf44336, |
|
|
securityAlert: 0xf44336, |
|
|
rateLimitRecovery: 0x4caf50, |
|
|
test: 0x2196f3 |
|
|
} |
|
|
|
|
|
return colors[type] || 0x9e9e9e |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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() |
|
|
|