diff --git a/VERSION b/VERSION index d24bae79..1b033d76 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.140 +1.1.142 diff --git a/src/routes/webhook.js b/src/routes/webhook.js index 4af05fb6..98cd3d44 100644 --- a/src/routes/webhook.js +++ b/src/routes/webhook.js @@ -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, diff --git a/src/services/webhookConfigService.js b/src/services/webhookConfigService.js index df0f36fb..39ca7265 100644 --- a/src/services/webhookConfigService.js +++ b/src/services/webhookConfigService.js @@ -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 diff --git a/src/services/webhookService.js b/src/services/webhookService.js index 4aa66cd9..d380b329 100755 --- a/src/services/webhookService.js +++ b/src/services/webhookService.js @@ -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消息 */ diff --git a/web/admin-spa/src/views/SettingsView.vue b/web/admin-spa/src/views/SettingsView.vue index 4c6355c5..19e29cff 100644 --- a/web/admin-spa/src/views/SettingsView.vue +++ b/web/admin-spa/src/views/SettingsView.vue @@ -470,12 +470,42 @@
+ 在 Telegram 的 @BotFather 中创建机器人后获得的 Token +
++ 可使用 @userinfobot、@RawDataBot 或 API 获取聊天/频道的 Chat ID +
++ 使用自建 Bot API 时可覆盖默认域名,需以 http 或 https 开头 +
++ 支持 http、https、socks4/4a/5 代理,留空则直接连接 Telegram 官方 API +
+