From f2dc834bba55dcf180c5ee4c4383e21df9e63062 Mon Sep 17 00:00:00 2001 From: wfunc <114468522+wfunc@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:44:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20telegram=20?= =?UTF-8?q?=E9=80=9A=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/routes/webhook.js | 77 ++++++- src/services/webhookConfigService.js | 40 +++- src/services/webhookService.js | 126 ++++++++++- web/admin-spa/src/views/SettingsView.vue | 262 ++++++++++++++++++++++- 4 files changed, 491 insertions(+), 14 deletions(-) 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 @@
{{ platform.url }}
+
+ + Chat ID: {{ platform.chatId || '未配置' }} +
+
+ + Token: {{ formatTelegramToken(platform.botToken) }} +
+
+ + API: {{ platform.apiBaseUrl }} +
+
+ + 代理: {{ platform.proxyUrl }} +
🟦 飞书 + @@ -696,7 +727,13 @@
-
+
+ +
+
+ + +

+ 在 Telegram 的 @BotFather 中创建机器人后获得的 Token +

+
+ +
+ + +

+ 可使用 @userinfobot、@RawDataBot 或 API 获取聊天/频道的 Chat ID +

+
+ +
+ + +

+ 使用自建 Bot API 时可覆盖默认域名,需以 http 或 https 开头 +

+
+ +
+ + +

+ 支持 http、https、socks4/4a/5 代理,留空则直接连接 Telegram 官方 API +

+
+ +
+ +
机器人需先加入对应群组或频道并授予发送消息权限,通知会以纯文本方式发送。
+
+
+
@@ -1155,6 +1280,11 @@ const platformForm = ref({ url: '', enableSign: false, secret: '', + // Telegram特有字段 + botToken: '', + chatId: '', + apiBaseUrl: '', + proxyUrl: '', // Bark特有字段 deviceKey: '', serverUrl: '', @@ -1196,6 +1326,11 @@ const platformTypeWatcher = watch( platformForm.value.url = '' platformForm.value.enableSign = false platformForm.value.secret = '' + // 清空Telegram字段 + platformForm.value.botToken = '' + platformForm.value.chatId = '' + platformForm.value.apiBaseUrl = '' + platformForm.value.proxyUrl = '' // 清空SMTP字段 platformForm.value.host = '' platformForm.value.port = null @@ -1217,6 +1352,33 @@ const platformTypeWatcher = watch( platformForm.value.level = '' platformForm.value.sound = '' platformForm.value.group = '' + // 清空Telegram字段 + platformForm.value.botToken = '' + platformForm.value.chatId = '' + platformForm.value.apiBaseUrl = '' + platformForm.value.proxyUrl = '' + } else if (newType === 'telegram') { + platformForm.value.url = '' + platformForm.value.enableSign = false + platformForm.value.secret = '' + platformForm.value.deviceKey = '' + platformForm.value.serverUrl = '' + platformForm.value.level = '' + platformForm.value.sound = '' + platformForm.value.group = '' + platformForm.value.host = '' + platformForm.value.port = null + platformForm.value.secure = false + platformForm.value.user = '' + platformForm.value.pass = '' + platformForm.value.from = '' + platformForm.value.to = '' + platformForm.value.timeout = null + platformForm.value.ignoreTLS = false + platformForm.value.botToken = '' + platformForm.value.chatId = '' + platformForm.value.apiBaseUrl = '' + platformForm.value.proxyUrl = '' } else { // 切换到其他平台时,清空Bark和SMTP相关字段 platformForm.value.deviceKey = '' @@ -1234,6 +1396,11 @@ const platformTypeWatcher = watch( platformForm.value.to = '' platformForm.value.timeout = null platformForm.value.ignoreTLS = false + // Telegram 字段 + platformForm.value.botToken = '' + platformForm.value.chatId = '' + platformForm.value.apiBaseUrl = '' + platformForm.value.proxyUrl = '' } } } @@ -1244,6 +1411,9 @@ const isPlatformFormValid = computed(() => { if (platformForm.value.type === 'bark') { // Bark平台需要deviceKey return !!platformForm.value.deviceKey + } else if (platformForm.value.type === 'telegram') { + // Telegram需要机器人Token和Chat ID + return !!(platformForm.value.botToken && platformForm.value.chatId) } else if (platformForm.value.type === 'smtp') { // SMTP平台需要必要的配置 return !!( @@ -1336,7 +1506,7 @@ const saveWebhookConfig = async () => { // 验证 URL const validateUrl = () => { // Bark和SMTP平台不需要验证URL - if (platformForm.value.type === 'bark' || platformForm.value.type === 'smtp') { + if (['bark', 'smtp', 'telegram'].includes(platformForm.value.type)) { urlError.value = false urlValid.value = false return @@ -1371,6 +1541,40 @@ const validatePlatformForm = () => { showToast('请输入Bark设备密钥', 'error') return false } + } else if (platformForm.value.type === 'telegram') { + if (!platformForm.value.botToken) { + showToast('请输入 Telegram 机器人 Token', 'error') + return false + } + if (!platformForm.value.chatId) { + showToast('请输入 Telegram Chat ID', 'error') + return false + } + if (platformForm.value.apiBaseUrl) { + try { + const parsed = new URL(platformForm.value.apiBaseUrl) + if (!['http:', 'https:'].includes(parsed.protocol)) { + showToast('Telegram API 基础地址仅支持 http 或 https', 'error') + return false + } + } catch (error) { + showToast('请输入有效的 Telegram API 基础地址', 'error') + return false + } + } + if (platformForm.value.proxyUrl) { + try { + const parsed = new URL(platformForm.value.proxyUrl) + const supportedProtocols = ['http:', 'https:', 'socks4:', 'socks4a:', 'socks5:'] + if (!supportedProtocols.includes(parsed.protocol)) { + showToast('Telegram 代理仅支持 http/https/socks 协议', 'error') + return false + } + } catch (error) { + showToast('请输入有效的 Telegram 代理地址', 'error') + return false + } + } } else if (platformForm.value.type === 'smtp') { const requiredFields = [ { field: 'host', message: 'SMTP服务器' }, @@ -1442,7 +1646,34 @@ const savePlatform = async () => { // 编辑平台 const editPlatform = (platform) => { editingPlatform.value = platform - platformForm.value = { ...platform } + platformForm.value = { + type: platform.type || 'wechat_work', + name: platform.name || '', + url: platform.url || '', + enableSign: platform.enableSign || false, + secret: platform.secret || '', + // Telegram特有字段 + botToken: platform.botToken || '', + chatId: platform.chatId || '', + apiBaseUrl: platform.apiBaseUrl || '', + proxyUrl: platform.proxyUrl || '', + // Bark特有字段 + deviceKey: platform.deviceKey || '', + serverUrl: platform.serverUrl || '', + level: platform.level || '', + sound: platform.sound || '', + group: platform.group || '', + // SMTP特有字段 + host: platform.host || '', + port: platform.port ?? null, + secure: platform.secure || false, + user: platform.user || '', + pass: platform.pass || '', + from: platform.from || '', + to: Array.isArray(platform.to) ? platform.to.join(', ') : platform.to || '', + timeout: platform.timeout ?? null, + ignoreTLS: platform.ignoreTLS || false + } showAddPlatformModal.value = true } @@ -1521,6 +1752,11 @@ const testPlatform = async (platform) => { testData.from = platform.from testData.to = platform.to testData.ignoreTLS = platform.ignoreTLS + } else if (platform.type === 'telegram') { + testData.botToken = platform.botToken + testData.chatId = platform.chatId + testData.apiBaseUrl = platform.apiBaseUrl + testData.proxyUrl = platform.proxyUrl } else { testData.url = platform.url } @@ -1584,7 +1820,9 @@ const sendTestNotification = async () => { } catch (error) { if (error.name === 'AbortError') return if (!isMounted.value) return - showToast('发送失败', 'error') + const errorMessage = + error?.response?.data?.message || error?.response?.data?.error || error?.message || '发送失败' + showToast(errorMessage, 'error') console.error(error) } } @@ -1605,6 +1843,11 @@ const closePlatformModal = () => { url: '', enableSign: false, secret: '', + // Telegram特有字段 + botToken: '', + chatId: '', + apiBaseUrl: '', + proxyUrl: '', // Bark特有字段 deviceKey: '', serverUrl: '', @@ -1637,6 +1880,7 @@ const getPlatformName = (type) => { feishu: '飞书', slack: 'Slack', discord: 'Discord', + telegram: 'Telegram', bark: 'Bark', smtp: '邮件通知', custom: '自定义' @@ -1651,6 +1895,7 @@ const getPlatformIcon = (type) => { feishu: 'fas fa-dove text-blue-600', slack: 'fab fa-slack text-purple-600', discord: 'fab fa-discord text-indigo-600', + telegram: 'fab fa-telegram-plane text-sky-500', bark: 'fas fa-bell text-orange-500', smtp: 'fas fa-envelope text-blue-600', custom: 'fas fa-webhook text-gray-600' @@ -1665,6 +1910,7 @@ const getWebhookHint = (type) => { feishu: '请在飞书群机器人设置中获取Webhook地址', slack: '请在Slack应用的Incoming Webhooks中获取地址', discord: '请在Discord服务器的集成设置中创建Webhook', + telegram: '使用 @BotFather 创建机器人并复制 Token,Chat ID 可通过 @userinfobot 或相关工具获取', bark: '请在Bark App中查看您的设备密钥', smtp: '请配置SMTP服务器信息,支持Gmail、QQ邮箱等', custom: '请输入完整的Webhook接收地址' @@ -1672,6 +1918,12 @@ const getWebhookHint = (type) => { return hints[type] || '' } +const formatTelegramToken = (token) => { + if (!token) return '' + if (token.length <= 12) return token + return `${token.slice(0, 6)}...${token.slice(-4)}` +} + const getNotificationTypeName = (type) => { const names = { accountAnomaly: '账号异常',