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