feat: 添加 Telegram 通知支持,更新配置和文档

This commit is contained in:
cita
2026-03-03 17:25:27 +08:00
parent cf6b2285fb
commit 9ff138f0b5
13 changed files with 295 additions and 4 deletions
+3
View File
@@ -5,6 +5,9 @@ BALANCE_REFRESH_CRON=0 * * * *
WEBHOOK_URL= WEBHOOK_URL=
BARK_URL= BARK_URL=
SERVERCHAN_KEY= SERVERCHAN_KEY=
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
PORT=4000 PORT=4000
DATA_DIR=./data DATA_DIR=./data
NOTIFY_COOLDOWN_SEC=300 NOTIFY_COOLDOWN_SEC=300
+1 -1
View File
@@ -212,7 +212,7 @@
- **自动签到**:Cron 定时执行,智能解析奖励金额,签到失败自动通知 - **自动签到**:Cron 定时执行,智能解析奖励金额,签到失败自动通知
- **余额管理**:定时刷新,收入追踪,余额兜底估算,凭证过期自动重登录 - **余额管理**:定时刷新,收入追踪,余额兜底估算,凭证过期自动重登录
- **告警通知**:支持 Webhook / Bark / Server酱 / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景 - **告警通知**:支持 Webhook / Bark / Server酱 / Telegram Bot / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景
- **数据看板**:余额分布饼图、消费趋势图表、全局搜索、代理请求日志 - **数据看板**:余额分布饼图、消费趋势图表、全局搜索、代理请求日志
<div align="center"> <div align="center">
+3 -2
View File
@@ -228,13 +228,14 @@ Each adapter supports: account login, balance queries, model enumeration, token
### Alerts & Notifications ### Alerts & Notifications
Four notification channels supported: Five notification channels supported:
| Channel | Description | | Channel | Description |
| --- | --- | | --- | --- |
| **Webhook** | Custom HTTP push | | **Webhook** | Custom HTTP push |
| **Bark** | iOS push notifications | | **Bark** | iOS push notifications |
| **ServerChan** | WeChat / Telegram notifications | | **ServerChan** | WeChat notifications |
| **Telegram Bot** | Telegram message notifications |
| **SMTP Email** | Standard email 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. 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.
+3
View File
@@ -10,6 +10,9 @@ PROXY_TOKEN=change-me-proxy-sk-token
PORT=4000 PORT=4000
NOTIFY_COOLDOWN_SEC=300 NOTIFY_COOLDOWN_SEC=300
ADMIN_IP_ALLOWLIST= ADMIN_IP_ALLOWLIST=
TELEGRAM_ENABLED=false
TELEGRAM_BOT_TOKEN=
TELEGRAM_CHAT_ID=
# Auto check-in cron (default: 8:00 AM every day) # Auto check-in cron (default: 8:00 AM every day)
CHECKIN_CRON=0 8 * * * CHECKIN_CRON=0 8 * * *
+18
View File
@@ -123,6 +123,24 @@ Metapi 的路由引擎按多因子加权选择最优通道。
| `SERVERCHAN_ENABLED` | 启用 Server酱 通知 | `true` | | `SERVERCHAN_ENABLED` | 启用 Server酱 通知 | `true` |
| `SERVERCHAN_KEY` | Server酱 SendKey | 空 | | `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 邮件 ### SMTP 邮件
| 变量名 | 说明 | 默认值 | | 变量名 | 说明 | 默认值 |
+3
View File
@@ -33,6 +33,9 @@ export const config = {
barkEnabled: parseBoolean(process.env.BARK_ENABLED, true), barkEnabled: parseBoolean(process.env.BARK_ENABLED, true),
serverChanEnabled: parseBoolean(process.env.SERVERCHAN_ENABLED, true), serverChanEnabled: parseBoolean(process.env.SERVERCHAN_ENABLED, true),
serverChanKey: process.env.SERVERCHAN_KEY || '', 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), smtpEnabled: parseBoolean(process.env.SMTP_ENABLED, false),
smtpHost: process.env.SMTP_HOST || '', smtpHost: process.env.SMTP_HOST || '',
smtpPort: parseInt(process.env.SMTP_PORT || '587'), smtpPort: parseInt(process.env.SMTP_PORT || '587'),
+9
View File
@@ -88,6 +88,15 @@ try {
const serverChanKey = parseSetting<string>('serverchan_key'); const serverChanKey = parseSetting<string>('serverchan_key');
if (typeof serverChanKey === 'string') config.serverChanKey = serverChanKey; if (typeof serverChanKey === 'string') config.serverChanKey = serverChanKey;
const telegramEnabled = parseSetting<boolean>('telegram_enabled');
if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled;
const telegramBotToken = parseSetting<string>('telegram_bot_token');
if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken;
const telegramChatId = parseSetting<string>('telegram_chat_id');
if (typeof telegramChatId === 'string') config.telegramChatId = telegramChatId;
const smtpEnabled = parseSetting<boolean>('smtp_enabled'); const smtpEnabled = parseSetting<boolean>('smtp_enabled');
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled; if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
@@ -132,6 +132,38 @@ describe('settings and auth events', () => {
expect(body.message).toContain('Webhook URL'); 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 () => { it('persists and returns routing fallback unit cost from runtime settings', async () => {
const updateResponse = await app.inject({ const updateResponse = await app.inject({
method: 'PUT', method: 'PUT',
+68
View File
@@ -21,6 +21,9 @@ interface RuntimeSettingsBody {
barkEnabled?: boolean; barkEnabled?: boolean;
serverChanEnabled?: boolean; serverChanEnabled?: boolean;
serverChanKey?: string; serverChanKey?: string;
telegramEnabled?: boolean;
telegramBotToken?: string;
telegramChatId?: string;
smtpEnabled?: boolean; smtpEnabled?: boolean;
smtpHost?: string; smtpHost?: string;
smtpPort?: number; smtpPort?: number;
@@ -155,6 +158,20 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.serverChanKey = value.trim(); config.serverChanKey = value.trim();
return; 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': { case 'smtp_enabled': {
config.smtpEnabled = !!value; config.smtpEnabled = !!value;
return; return;
@@ -239,6 +256,9 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
barkEnabled: config.barkEnabled, barkEnabled: config.barkEnabled,
serverChanEnabled: config.serverChanEnabled, serverChanEnabled: config.serverChanEnabled,
serverChanKeyMasked: maskSecret(config.serverChanKey), serverChanKeyMasked: maskSecret(config.serverChanKey),
telegramEnabled: config.telegramEnabled,
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
telegramChatId: config.telegramChatId,
smtpEnabled: config.smtpEnabled, smtpEnabled: config.smtpEnabled,
smtpHost: config.smtpHost, smtpHost: config.smtpHost,
smtpPort: config.smtpPort, 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 (body.checkinCron !== undefined) {
if (!cron.validate(body.checkinCron)) { if (!cron.validate(body.checkinCron)) {
return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' }); return reply.code(400).send({ success: false, message: '签到 Cron 表达式无效' });
@@ -382,6 +426,30 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('serverchan_key', config.serverChanKey); 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 !== undefined) {
if (!!body.smtpEnabled !== config.smtpEnabled) { if (!!body.smtpEnabled !== config.smtpEnabled) {
changedLabels.push('SMTP 开关'); changedLabels.push('SMTP 开关');
@@ -187,4 +187,16 @@ describe('resolveUpstreamEndpointCandidates', () => {
expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true); expect(isEndpointDowngradeError(405, '{"error":{"message":"Method Not Allowed"}}')).toBe(true);
expect(isEndpointDowngradeError(400, '{"error":{"message":"unsupported endpoint","type":"invalid_request_error"}}')).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);
});
}); });
+35
View File
@@ -32,6 +32,9 @@ describe('notifyService', () => {
config.barkUrl = ''; config.barkUrl = '';
config.serverChanEnabled = false; config.serverChanEnabled = false;
config.serverChanKey = ''; config.serverChanKey = '';
(config as any).telegramEnabled = false;
(config as any).telegramBotToken = '';
(config as any).telegramChatId = '';
config.smtpEnabled = true; config.smtpEnabled = true;
config.smtpHost = 'smtp.example.com'; config.smtpHost = 'smtp.example.com';
config.smtpPort = 465; config.smtpPort = 465;
@@ -116,4 +119,36 @@ describe('notifyService', () => {
expect(payload?.text || '').toContain('Local Time:'); expect(payload?.text || '').toContain('Local Time:');
expect(payload?.text || '').toContain('UTC 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:');
});
}); });
+42 -1
View File
@@ -9,7 +9,7 @@ import {
} from './notificationThrottle.js'; } from './notificationThrottle.js';
import { formatLocalDateTime, getResolvedTimeZone } from './localTimeService.js'; import { formatLocalDateTime, getResolvedTimeZone } from './localTimeService.js';
type NotificationChannel = 'webhook' | 'bark' | 'serverchan' | 'smtp'; type NotificationChannel = 'webhook' | 'bark' | 'serverchan' | 'telegram' | 'smtp';
export type SendNotificationOptions = { export type SendNotificationOptions = {
bypassThrottle?: boolean; bypassThrottle?: boolean;
@@ -70,6 +70,18 @@ function buildTimeFootnote(now: Date): string {
].join('\n'); ].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( export async function sendNotification(
title: string, title: string,
message: 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 ( if (
config.smtpEnabled && config.smtpEnabled &&
config.smtpHost && config.smtpHost &&
+66
View File
@@ -9,6 +9,8 @@ type RuntimeSettings = {
webhookEnabled: boolean; webhookEnabled: boolean;
barkEnabled: boolean; barkEnabled: boolean;
serverChanEnabled: boolean; serverChanEnabled: boolean;
telegramEnabled: boolean;
telegramChatId: string;
smtpEnabled: boolean; smtpEnabled: boolean;
smtpHost: string; smtpHost: string;
smtpPort: number; smtpPort: number;
@@ -18,6 +20,7 @@ type RuntimeSettings = {
smtpFrom: string; smtpFrom: string;
smtpTo: string; smtpTo: string;
serverChanKeyMasked?: string; serverChanKeyMasked?: string;
telegramBotTokenMasked?: string;
notifyCooldownSec: number; notifyCooldownSec: number;
}; };
@@ -28,6 +31,8 @@ export default function NotificationSettings() {
webhookEnabled: true, webhookEnabled: true,
barkEnabled: true, barkEnabled: true,
serverChanEnabled: false, serverChanEnabled: false,
telegramEnabled: false,
telegramChatId: '',
smtpEnabled: false, smtpEnabled: false,
smtpHost: '', smtpHost: '',
smtpPort: 587, smtpPort: 587,
@@ -39,6 +44,7 @@ export default function NotificationSettings() {
}); });
const [serverChanKey, setServerChanKey] = useState(''); const [serverChanKey, setServerChanKey] = useState('');
const [telegramBotToken, setTelegramBotToken] = useState('');
const [smtpPass, setSmtpPass] = useState(''); const [smtpPass, setSmtpPass] = useState('');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [savingNotify, setSavingNotify] = useState(false); const [savingNotify, setSavingNotify] = useState(false);
@@ -67,6 +73,8 @@ export default function NotificationSettings() {
webhookEnabled: runtimeInfo.webhookEnabled ?? true, webhookEnabled: runtimeInfo.webhookEnabled ?? true,
barkEnabled: runtimeInfo.barkEnabled ?? true, barkEnabled: runtimeInfo.barkEnabled ?? true,
serverChanEnabled: !!runtimeInfo.serverChanEnabled, serverChanEnabled: !!runtimeInfo.serverChanEnabled,
telegramEnabled: !!runtimeInfo.telegramEnabled,
telegramChatId: runtimeInfo.telegramChatId || '',
smtpEnabled: !!runtimeInfo.smtpEnabled, smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '', smtpHost: runtimeInfo.smtpHost || '',
smtpPort: Number(runtimeInfo.smtpPort) || 587, smtpPort: Number(runtimeInfo.smtpPort) || 587,
@@ -76,6 +84,7 @@ export default function NotificationSettings() {
smtpFrom: runtimeInfo.smtpFrom || '', smtpFrom: runtimeInfo.smtpFrom || '',
smtpTo: runtimeInfo.smtpTo || '', smtpTo: runtimeInfo.smtpTo || '',
serverChanKeyMasked: runtimeInfo.serverChanKeyMasked || '', serverChanKeyMasked: runtimeInfo.serverChanKeyMasked || '',
telegramBotTokenMasked: runtimeInfo.telegramBotTokenMasked || '',
notifyCooldownSec: Number.isFinite(Number(runtimeInfo.notifyCooldownSec)) notifyCooldownSec: Number.isFinite(Number(runtimeInfo.notifyCooldownSec))
? Math.max(0, Math.trunc(Number(runtimeInfo.notifyCooldownSec))) ? Math.max(0, Math.trunc(Number(runtimeInfo.notifyCooldownSec)))
: 300, : 300,
@@ -100,6 +109,8 @@ export default function NotificationSettings() {
webhookEnabled: runtime.webhookEnabled, webhookEnabled: runtime.webhookEnabled,
barkEnabled: runtime.barkEnabled, barkEnabled: runtime.barkEnabled,
serverChanEnabled: runtime.serverChanEnabled, serverChanEnabled: runtime.serverChanEnabled,
telegramEnabled: runtime.telegramEnabled,
telegramChatId: runtime.telegramChatId,
smtpEnabled: runtime.smtpEnabled, smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost, smtpHost: runtime.smtpHost,
smtpPort: runtime.smtpPort, smtpPort: runtime.smtpPort,
@@ -110,15 +121,18 @@ export default function NotificationSettings() {
notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)), notifyCooldownSec: Math.max(0, Math.trunc(Number(runtime.notifyCooldownSec) || 0)),
}; };
if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim(); if (serverChanKey.trim()) payload.serverChanKey = serverChanKey.trim();
if (telegramBotToken.trim()) payload.telegramBotToken = telegramBotToken.trim();
if (smtpPass.trim()) payload.smtpPass = smtpPass.trim(); if (smtpPass.trim()) payload.smtpPass = smtpPass.trim();
const res = await api.updateRuntimeSettings(payload); const res = await api.updateRuntimeSettings(payload);
setRuntime((prev) => ({ setRuntime((prev) => ({
...prev, ...prev,
serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked, serverChanKeyMasked: res.serverChanKeyMasked || prev.serverChanKeyMasked,
telegramBotTokenMasked: res.telegramBotTokenMasked || prev.telegramBotTokenMasked,
smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked, smtpPassMasked: res.smtpPassMasked || prev.smtpPassMasked,
})); }));
setServerChanKey(''); setServerChanKey('');
setTelegramBotToken('');
setSmtpPass(''); setSmtpPass('');
toast.success('通知设置已保存'); toast.success('通知设置已保存');
} catch (err: any) { } catch (err: any) {
@@ -286,6 +300,58 @@ export default function NotificationSettings() {
</div> </div>
</div> </div>
{/* 卡片:Telegram */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.telegramEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div style={{ width: 32, height: 32, borderRadius: 8, background: 'var(--color-primary-light)', color: 'var(--color-primary)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<svg width="18" height="18" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 11l18-8-6 18-3-7-9-3z" /></svg>
</div>
<div>
<div style={{ fontWeight: 600, fontSize: 15 }}>Telegram Bot</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>通过 Telegram 机器人推送消息通知</div>
</div>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<span style={{ fontSize: 13, fontWeight: 500, color: runtime.telegramEnabled ? 'var(--color-primary)' : 'var(--color-text-muted)' }}>启用 Telegram</span>
<input
type="checkbox"
style={{ width: 16, height: 16, cursor: 'pointer' }}
checked={runtime.telegramEnabled}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramEnabled: e.target.checked }))}
/>
</label>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', gap: '16px 20px', opacity: runtime.telegramEnabled ? 1 : 0.6, transition: 'opacity 0.2s' }}>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Chat ID</div>
<input
value={runtime.telegramChatId}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramChatId: e.target.value }))}
placeholder="例如: -1001234567890 或 @your_channel"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>
Telegram Bot Token
{runtime.telegramBotTokenMasked && <span style={{ color: 'var(--color-primary)', marginLeft: 8, fontSize: 12 }}>(当前已设置)</span>}
</div>
<input
type="password"
value={telegramBotToken}
onChange={(e) => setTelegramBotToken(e.target.value)}
placeholder="输入新的 Bot Token(留空则不改)"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
</div>
</div>
</div>
{/* 卡片:SMTP 邮件设置 */} {/* 卡片:SMTP 邮件设置 */}
<div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}> <div className="card animate-slide-up stagger-4" style={{ padding: 24, border: runtime.smtpEnabled ? '1px solid var(--color-primary)' : '1px solid var(--color-border-light)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 16 }}>