feat: 新增 telegram 通知

This commit is contained in:
wfunc
2025-09-16 11:44:39 +08:00
parent 932b0e3f9d
commit f2dc834bba
4 changed files with 491 additions and 14 deletions

View File

@@ -133,7 +133,11 @@ router.post('/test', authenticateAdmin, async (req, res) => {
pass,
from,
to,
ignoreTLS
ignoreTLS,
botToken,
chatId,
apiBaseUrl,
proxyUrl
} = req.body
// Bark平台特殊处理
@@ -186,6 +190,56 @@ router.post('/test', authenticateAdmin, async (req, res) => {
}
logger.info(`🧪 测试webhook: ${type} - ${host}:${port || 587} -> ${to}`)
} else if (type === 'telegram') {
if (!botToken) {
return res.status(400).json({
error: 'Missing Telegram bot token',
message: '请提供 Telegram 机器人 Token'
})
}
if (!chatId) {
return res.status(400).json({
error: 'Missing Telegram chat id',
message: '请提供 Telegram Chat ID'
})
}
if (apiBaseUrl) {
try {
const parsed = new URL(apiBaseUrl)
if (!['http:', 'https:'].includes(parsed.protocol)) {
return res.status(400).json({
error: 'Invalid Telegram API base url protocol',
message: 'Telegram API 基础地址仅支持 http 或 https'
})
}
} catch (urlError) {
return res.status(400).json({
error: 'Invalid Telegram API base url',
message: '请提供有效的 Telegram API 基础地址'
})
}
}
if (proxyUrl) {
try {
const parsed = new URL(proxyUrl)
const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:']
if (!supportedProtocols.includes(parsed.protocol)) {
return res.status(400).json({
error: 'Unsupported proxy protocol',
message: 'Telegram 代理仅支持 http/https/socks 协议'
})
}
} catch (urlError) {
return res.status(400).json({
error: 'Invalid proxy url',
message: '请提供有效的代理地址'
})
}
}
logger.info(`🧪 测试webhook: ${type} - Chat ID: ${chatId}`)
} else {
// 其他平台验证URL
if (!url) {
@@ -235,12 +289,30 @@ router.post('/test', authenticateAdmin, async (req, res) => {
platform.from = from
platform.to = to
platform.ignoreTLS = ignoreTLS || false
} else if (type === 'telegram') {
platform.botToken = botToken
platform.chatId = chatId
platform.apiBaseUrl = apiBaseUrl
platform.proxyUrl = proxyUrl
}
const result = await webhookService.testWebhook(platform)
const identifier = (() => {
if (type === 'bark') {
return `Device: ${deviceKey.substring(0, 8)}...`
}
if (type === 'smtp') {
const recipients = Array.isArray(to) ? to.join(', ') : to
return `${host}:${port || 587} -> ${recipients}`
}
if (type === 'telegram') {
return `Chat ID: ${chatId}`
}
return url
})()
if (result.success) {
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
logger.info(`✅ Webhook测试成功: ${identifier}`)
res.json({
success: true,
@@ -249,7 +321,6 @@ router.post('/test', authenticateAdmin, async (req, res) => {
deviceKey: type === 'bark' ? `${deviceKey.substring(0, 8)}...` : undefined
})
} else {
const identifier = type === 'bark' ? `Device: ${deviceKey.substring(0, 8)}...` : url
logger.warn(`❌ Webhook测试失败: ${identifier} - ${result.error}`)
res.status(400).json({
success: false,

View File

@@ -62,6 +62,7 @@ class WebhookConfigService {
'feishu',
'slack',
'discord',
'telegram',
'custom',
'bark',
'smtp'
@@ -73,7 +74,7 @@ class WebhookConfigService {
}
// Bark和SMTP平台不使用标准URL
if (platform.type !== 'bark' && platform.type !== 'smtp') {
if (!['bark', 'smtp', 'telegram'].includes(platform.type)) {
if (!platform.url || !this.isValidUrl(platform.url)) {
throw new Error(`无效的webhook URL: ${platform.url}`)
}
@@ -117,6 +118,43 @@ class WebhookConfigService {
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':
// 自定义webhook用户自行负责格式
break

View File

@@ -1,6 +1,8 @@
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')
@@ -14,6 +16,7 @@ class WebhookService {
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)
@@ -218,6 +221,38 @@ class WebhookService {
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
*/
@@ -293,13 +328,17 @@ class WebhookService {
/**
* 发送HTTP请求
*/
async sendHttpRequest(url, payload, timeout) {
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,
headers: {
'Content-Type': 'application/json',
'User-Agent': 'claude-relay-service/2.0'
}
...axiosOptions,
headers
})
if (response.status < 200 || response.status >= 300) {
@@ -394,6 +433,83 @@ class WebhookService {
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消息
*/