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 邮件设置 */}