diff --git a/.env.example b/.env.example index 25fbcb9..029ad7c 100644 --- a/.env.example +++ b/.env.example @@ -5,6 +5,9 @@ BALANCE_REFRESH_CRON=0 * * * * WEBHOOK_URL= BARK_URL= SERVERCHAN_KEY= +TELEGRAM_ENABLED=false +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= PORT=4000 DATA_DIR=./data NOTIFY_COOLDOWN_SEC=300 diff --git a/README.md b/README.md index 29dfd98..65caad2 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,7 @@ - **自动签到**:Cron 定时执行,智能解析奖励金额,签到失败自动通知 - **余额管理**:定时刷新,收入追踪,余额兜底估算,凭证过期自动重登录 -- **告警通知**:支持 Webhook / Bark / Server酱 / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景 +- **告警通知**:支持 Webhook / Bark / Server酱 / Telegram Bot / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景 - **数据看板**:余额分布饼图、消费趋势图表、全局搜索、代理请求日志
diff --git a/README_EN.md b/README_EN.md index 9dc9665..519047e 100644 --- a/README_EN.md +++ b/README_EN.md @@ -228,13 +228,14 @@ Each adapter supports: account login, balance queries, model enumeration, token ### Alerts & Notifications -Four notification channels supported: +Five notification channels supported: | Channel | Description | | --- | --- | | **Webhook** | Custom HTTP push | | **Bark** | iOS push notifications | -| **ServerChan** | WeChat / Telegram notifications | +| **ServerChan** | WeChat notifications | +| **Telegram Bot** | Telegram message notifications | | **SMTP Email** | Standard email notifications | Alert scenarios: low balance warning, site/account anomalies, check-in failures, proxy request failures, token expiry reminders, daily summary reports. Alert cooldown mechanism (default: 300 seconds) prevents duplicate notifications. diff --git a/docker/.env.example b/docker/.env.example index 6a9d0bc..ea1c7a8 100644 --- a/docker/.env.example +++ b/docker/.env.example @@ -10,6 +10,9 @@ PROXY_TOKEN=change-me-proxy-sk-token PORT=4000 NOTIFY_COOLDOWN_SEC=300 ADMIN_IP_ALLOWLIST= +TELEGRAM_ENABLED=false +TELEGRAM_BOT_TOKEN= +TELEGRAM_CHAT_ID= # Auto check-in cron (default: 8:00 AM every day) CHECKIN_CRON=0 8 * * * diff --git a/docs/configuration.md b/docs/configuration.md index ee4e8bd..4707b8a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -123,6 +123,24 @@ Metapi 的路由引擎按多因子加权选择最优通道。 | `SERVERCHAN_ENABLED` | 启用 Server酱 通知 | `true` | | `SERVERCHAN_KEY` | Server酱 SendKey | 空 | +### Telegram Bot + +| 变量名 | 说明 | 默认值 | +|--------|------|--------| +| `TELEGRAM_ENABLED` | 启用 Telegram 通知 | `false` | +| `TELEGRAM_BOT_TOKEN` | Telegram Bot Token(形如 `123456:abc`) | 空 | +| `TELEGRAM_CHAT_ID` | 接收消息的 Chat ID(如 `-100xxxx` 或 `@channel`) | 空 | + +**配置步骤:** + +1. **创建 Bot**:在 Telegram 中搜索 [@BotFather](https://t.me/BotFather),发送 `/newbot`,按提示设置名称后获取 Bot Token(格式如 `123456789:ABCdefGHIjklMNOpqrsTUVwxyz`) +2. **获取 Chat ID**: + - **个人聊天**:向你的 Bot 发送任意消息,然后访问 `https://api.telegram.org/bot<你的Token>/getUpdates`,在返回的 JSON 中找到 `chat.id`,或者在 Telegram 搜索 @userinfobot 或是 @getmyid_bot。点击 Start,它会立刻回复一串数字(通常是 9 到 10 位)。 + - **群组**:将 Bot 邀请进群组,在群内发送消息后同样通过 `getUpdates` 接口获取群组 Chat ID(通常为负数,如 `-1001234567890`) + - **频道**:使用频道用户名,如 `@your_channel`(需先将 Bot 添加为频道管理员) +3. **填入配置**:将获取的 Token 和 Chat ID 填入环境变量或在管理后台「通知设置」页面中配置 +4. **测试**:保存后点击页面上的「测试通知」按钮验证是否收到消息 + ### SMTP 邮件 | 变量名 | 说明 | 默认值 | diff --git a/src/server/config.ts b/src/server/config.ts index 9cb3dd2..7e971d1 100644 --- a/src/server/config.ts +++ b/src/server/config.ts @@ -33,6 +33,9 @@ export const config = { barkEnabled: parseBoolean(process.env.BARK_ENABLED, true), serverChanEnabled: parseBoolean(process.env.SERVERCHAN_ENABLED, true), serverChanKey: process.env.SERVERCHAN_KEY || '', + telegramEnabled: parseBoolean(process.env.TELEGRAM_ENABLED, false), + telegramBotToken: process.env.TELEGRAM_BOT_TOKEN || '', + telegramChatId: process.env.TELEGRAM_CHAT_ID || '', smtpEnabled: parseBoolean(process.env.SMTP_ENABLED, false), smtpHost: process.env.SMTP_HOST || '', smtpPort: parseInt(process.env.SMTP_PORT || '587'), diff --git a/src/server/index.ts b/src/server/index.ts index d8d407b..0fd085d 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -88,6 +88,15 @@ try { const serverChanKey = parseSetting('serverchan_key'); if (typeof serverChanKey === 'string') config.serverChanKey = serverChanKey; + const telegramEnabled = parseSetting('telegram_enabled'); + if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled; + + const telegramBotToken = parseSetting('telegram_bot_token'); + if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken; + + const telegramChatId = parseSetting('telegram_chat_id'); + if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId; + const smtpEnabled = parseSetting('smtp_enabled'); if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled; diff --git a/src/server/routes/api/settings.events.test.ts b/src/server/routes/api/settings.events.test.ts index 0ef7d40..b90cc2f 100644 --- a/src/server/routes/api/settings.events.test.ts +++ b/src/server/routes/api/settings.events.test.ts @@ -132,6 +132,38 @@ describe('settings and auth events', () => { expect(body.message).toContain('Webhook URL'); }); + it('rejects telegram config when bot token is missing but telegram is enabled', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramEnabled: true, + telegramChatId: '-1001234567890', + telegramBotToken: '', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json() as { message?: string }; + expect(body.message).toContain('Telegram Bot Token'); + }); + + it('rejects telegram config when chat id is missing but telegram is enabled', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/api/settings/runtime', + payload: { + telegramEnabled: true, + telegramBotToken: '123456:telegram-token', + telegramChatId: '', + }, + }); + + expect(response.statusCode).toBe(400); + const body = response.json() as { message?: string }; + expect(body.message).toContain('Telegram Chat ID'); + }); + it('persists and returns routing fallback unit cost from runtime settings', async () => { const updateResponse = await app.inject({ method: 'PUT', diff --git a/src/server/routes/api/settings.ts b/src/server/routes/api/settings.ts index 30fe199..909d526 100644 --- a/src/server/routes/api/settings.ts +++ b/src/server/routes/api/settings.ts @@ -21,6 +21,9 @@ interface RuntimeSettingsBody { barkEnabled?: boolean; serverChanEnabled?: boolean; serverChanKey?: string; + telegramEnabled?: boolean; + telegramBotToken?: string; + telegramChatId?: string; smtpEnabled?: boolean; smtpHost?: string; smtpPort?: number; @@ -155,6 +158,20 @@ function applyImportedSettingToRuntime(key: string, value: unknown) { config.serverChanKey = value.trim(); return; } + case 'telegram_enabled': { + config.telegramEnabled = !!value; + return; + } + case 'telegram_bot_token': { + if (typeof value !== 'string') return; + config.telegramBotToken = value.trim(); + return; + } + case 'telegram_chat_id': { + if (typeof value !== 'string') return; + config.telegramChatId = value.trim(); + return; + } case 'smtp_enabled': { config.smtpEnabled = !!value; return; @@ -239,6 +256,9 @@ function getRuntimeSettingsResponse(currentAdminIp = '') { barkEnabled: config.barkEnabled, serverChanEnabled: config.serverChanEnabled, serverChanKeyMasked: maskSecret(config.serverChanKey), + telegramEnabled: config.telegramEnabled, + telegramBotTokenMasked: maskSecret(config.telegramBotToken), + telegramChatId: config.telegramChatId, smtpEnabled: config.smtpEnabled, smtpHost: config.smtpHost, smtpPort: config.smtpPort, @@ -297,6 +317,30 @@ export async function settingsRoutes(app: FastifyInstance) { } } + const telegramTouched = body.telegramEnabled !== undefined + || body.telegramBotToken !== undefined + || body.telegramChatId !== undefined; + const nextTelegramEnabled = body.telegramEnabled !== undefined + ? !!body.telegramEnabled + : config.telegramEnabled; + const nextTelegramBotToken = body.telegramBotToken !== undefined + ? String(body.telegramBotToken || '').trim() + : config.telegramBotToken; + const nextTelegramChatId = body.telegramChatId !== undefined + ? String(body.telegramChatId || '').trim() + : config.telegramChatId; + if (telegramTouched && nextTelegramEnabled) { + if (!nextTelegramBotToken) { + return reply.code(400).send({ success: false, message: 'Telegram Bot Token 不能为空(启用 Telegram 时)' }); + } + if (!nextTelegramBotToken.includes(':')) { + return reply.code(400).send({ success: false, message: 'Telegram Bot Token 格式无效(示例:123456:abcDEF)' }); + } + if (!nextTelegramChatId) { + return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' }); + } + } + if (body.checkinCron !== undefined) { if (!cron.validate(body.checkinCron)) { return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' }); @@ -382,6 +426,30 @@ export async function settingsRoutes(app: FastifyInstance) { upsertSetting('serverchan_key', config.serverChanKey); } + if (body.telegramEnabled !== undefined) { + if (!!body.telegramEnabled !== config.telegramEnabled) { + changedLabels.push('Telegram 开关'); + } + config.telegramEnabled = !!body.telegramEnabled; + upsertSetting('telegram_enabled', config.telegramEnabled); + } + + if (body.telegramBotToken !== undefined) { + if (String(body.telegramBotToken || '').trim() !== config.telegramBotToken) { + changedLabels.push('Telegram Bot Token'); + } + config.telegramBotToken = String(body.telegramBotToken || '').trim(); + upsertSetting('telegram_bot_token', config.telegramBotToken); + } + + if (body.telegramChatId !== undefined) { + if (String(body.telegramChatId || '').trim() !== config.telegramChatId) { + changedLabels.push('Telegram Chat ID'); + } + config.telegramChatId = String(body.telegramChatId || '').trim(); + upsertSetting('telegram_chat_id', config.telegramChatId); + } + if (body.smtpEnabled !== undefined) { if (!!body.smtpEnabled !== config.smtpEnabled) { changedLabels.push('SMTP 开关'); diff --git a/src/server/routes/proxy/upstreamEndpoint.test.ts b/src/server/routes/proxy/upstreamEndpoint.test.ts index a0b87d2..f94d20a 100644 --- a/src/server/routes/proxy/upstreamEndpoint.test.ts +++ b/src/server/routes/proxy/upstreamEndpoint.test.ts @@ -187,4 +187,16 @@ describe('resolveUpstreamEndpointCandidates', () => { expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true); expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).toBe(true); }); + + it('treats Claude Code CLI-only restriction on responses as downgrade candidate', () => { + const upstreamError = JSON.stringify({ + error: { + code: 'invalid_request', + type: 'new_api_error', + message: '请勿在 Claude Code CLI 之外使用接口 (request id: abc123)', + }, + }); + + expect(isEndpointDowngradeError(400, upstreamError)).toBe(true); + }); }); diff --git a/src/server/services/notifyService.test.ts b/src/server/services/notifyService.test.ts index ed5a4c8..597f7b3 100644 --- a/src/server/services/notifyService.test.ts +++ b/src/server/services/notifyService.test.ts @@ -32,6 +32,9 @@ describe('notifyService', () => { config.barkUrl = ''; config.serverChanEnabled = false; config.serverChanKey = ''; + (config as any).telegramEnabled = false; + (config as any).telegramBotToken = ''; + (config as any).telegramChatId = ''; config.smtpEnabled = true; config.smtpHost = 'smtp.example.com'; config.smtpPort = 465; @@ -116,4 +119,36 @@ describe('notifyService', () => { expect(payload?.text || '').toContain('Local Time:'); expect(payload?.text || '').toContain('UTC Time:'); }); + + it('sends telegram message when telegram channel is enabled', async () => { + const { config } = await import('../config.js'); + (config as any).telegramEnabled = true; + (config as any).telegramBotToken = '123456:telegram-token'; + (config as any).telegramChatId = '-1001234567890'; + config.smtpEnabled = false; + + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + }); + + const { sendNotification } = await import('./notifyService.js'); + await sendNotification('测试通知', 'message', 'warning', { bypassThrottle: true, throwOnFailure: true }); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock).toHaveBeenCalledWith( + 'https://api.telegram.org/bot123456:telegram-token/sendMessage', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }), + ); + + const rawBody = fetchMock.mock.calls[0]?.[1] as { body?: string }; + const payload = JSON.parse(rawBody?.body || '{}') as { chat_id?: string; text?: string }; + expect(payload.chat_id).toBe('-1001234567890'); + expect(payload.text || '').toContain('Level: warning'); + expect(payload.text || '').toContain('Local Time:'); + expect(payload.text || '').toContain('UTC Time:'); + }); }); diff --git a/src/server/services/notifyService.ts b/src/server/services/notifyService.ts index b90f691..d13c6be 100644 --- a/src/server/services/notifyService.ts +++ b/src/server/services/notifyService.ts @@ -9,7 +9,7 @@ import { } from './notificationThrottle.js'; import { formatLocalDateTime, getResolvedTimeZone } from './localTimeService.js'; -type NotificationChannel = 'webhook' | 'bark' | 'serverchan' | 'smtp'; +type NotificationChannel = 'webhook' | 'bark' | 'serverchan' | 'telegram' | 'smtp'; export type SendNotificationOptions = { bypassThrottle?: boolean; @@ -70,6 +70,18 @@ function buildTimeFootnote(now: Date): string { ].join('\n'); } +function buildTelegramText( + title: string, + message: string, + level: 'info' | 'warning' | 'error', + timeFootnote: string, +): string { + const maxTextLength = 3900; + const raw = `[metapi][${level.toUpperCase()}] ${title}\n\n${message}\n\nLevel: ${level}\n${timeFootnote}`; + if (raw.length <= maxTextLength) return raw; + return `${raw.slice(0, maxTextLength)}\n\n...(truncated)`; +} + export async function sendNotification( title: string, message: string, @@ -163,6 +175,35 @@ export async function sendNotification( ); } + if (config.telegramEnabled && config.telegramBotToken && config.telegramChatId) { + const telegramApiUrl = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`; + const text = buildTelegramText(title, resolvedMessage, level, timeFootnote); + tasks.push({ + channel: 'telegram', + run: async () => { + const response = await fetch(telegramApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chat_id: config.telegramChatId, + text, + disable_web_page_preview: true, + }), + }); + if (!response.ok) { + throw new Error(`Telegram 响应状态 ${response.status}`); + } + let payload: { ok?: boolean; description?: string } | null = null; + try { + payload = await response.json() as { ok?: boolean; description?: string }; + } catch {} + if (payload?.ok === false) { + throw new Error(payload.description || 'Telegram 返回失败'); + } + }, + }); + } + if ( config.smtpEnabled && config.smtpHost && diff --git a/src/web/pages/NotificationSettings.tsx b/src/web/pages/NotificationSettings.tsx index b9ddc53..7b22d44 100644 --- a/src/web/pages/NotificationSettings.tsx +++ b/src/web/pages/NotificationSettings.tsx @@ -9,6 +9,8 @@ type RuntimeSettings = { webhookEnabled: boolean; barkEnabled: boolean; serverChanEnabled: boolean; + telegramEnabled: boolean; + telegramChatId: string; smtpEnabled: boolean; smtpHost: string; smtpPort: number; @@ -18,6 +20,7 @@ type RuntimeSettings = { smtpFrom: string; smtpTo: string; serverChanKeyMasked?: string; + telegramBotTokenMasked?: string; notifyCooldownSec: number; }; @@ -28,6 +31,8 @@ export default function NotificationSettings() { webhookEnabled: true, barkEnabled: true, serverChanEnabled: false, + telegramEnabled: false, + telegramChatId: '', smtpEnabled: false, smtpHost: '', smtpPort: 587, @@ -39,6 +44,7 @@ export default function NotificationSettings() { }); const [serverChanKey, setServerChanKey] = useState(''); + const [telegramBotToken, setTelegramBotToken] = useState(''); const [smtpPass, setSmtpPass] = useState(''); const [loading, setLoading] = useState(true); const [savingNotify, setSavingNotify] = useState(false); @@ -67,6 +73,8 @@ export default function NotificationSettings() { webhookEnabled: runtimeInfo.webhookEnabled ?? true, barkEnabled: runtimeInfo.barkEnabled ?? true, serverChanEnabled: !!runtimeInfo.serverChanEnabled, + telegramEnabled: !!runtimeInfo.telegramEnabled, + telegramChatId: runtimeInfo.telegramChatId || '', smtpEnabled: !!runtimeInfo.smtpEnabled, smtpHost: runtimeInfo.smtpHost || '', smtpPort: Number(runtimeInfo.smtpPort) || 587, @@ -76,6 +84,7 @@ export default function NotificationSettings() { smtpFrom: runtimeInfo.smtpFrom || '', smtpTo: runtimeInfo.smtpTo || '', serverChanKeyMasked: runtimeInfo.serverChanKeyMasked || '', + telegramBotTokenMasked: runtimeInfo.telegramBotTokenMasked || '', notifyCooldownSec: Number.isFinite(Number(runtimeInfo.notifyCooldownSec)) ? Math.max(0, Math.trunc(Number(runtimeInfo.notifyCooldownSec))) : 300, @@ -100,6 +109,8 @@ export default function NotificationSettings() { webhookEnabled: runtime.webhookEnabled, barkEnabled: runtime.barkEnabled, serverChanEnabled: runtime.serverChanEnabled, + telegramEnabled: runtime.telegramEnabled, + telegramChatId: runtime.telegramChatId, smtpEnabled: runtime.smtpEnabled, smtpHost: runtime.smtpHost, smtpPort: runtime.smtpPort, @@ -110,15 +121,18 @@ export default function NotificationSettings() { notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)), }; if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim(); + if (telegramBotToken.trim()) payload.telegramBotToken = telegramBotToken.trim(); if (smtpPass.trim()) payload.smtpPass = smtpPass.trim(); const res = await api.updateRuntimeSettings(payload); setRuntime((prev) => ({ ...prev, serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked, + telegramBotTokenMasked: res.telegramBotTokenMasked || prev.telegramBotTokenMasked, smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked, })); setServerChanKey(''); + setTelegramBotToken(''); setSmtpPass(''); toast.success('通知设置已保存'); } catch (err: any) { @@ -286,6 +300,58 @@ export default function NotificationSettings() {
+ {/* 卡片:Telegram */} +
+
+
+
+ +
+
+
Telegram Bot
+
通过 Telegram 机器人推送消息通知
+
+
+ + +
+ +
+
+
Telegram Chat ID
+ setRuntime((prev) => ({ ...prev, telegramChatId: e.target.value }))} + placeholder="例如: -1001234567890 或 @your_channel" + style={inputStyle} + disabled={!runtime.telegramEnabled} + /> +
+
+
+ Telegram Bot Token + {runtime.telegramBotTokenMasked && (当前已设置)} +
+ setTelegramBotToken(e.target.value)} + placeholder="输入新的 Bot Token(留空则不改)" + style={inputStyle} + disabled={!runtime.telegramEnabled} + /> +
+
+
+ {/* 卡片:SMTP 邮件设置 */}