forked from boos/BoosAPI
feat: 添加 Telegram 通知支持,更新配置和文档
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -212,7 +212,7 @@
|
|||||||
|
|
||||||
- **自动签到**:Cron 定时执行,智能解析奖励金额,签到失败自动通知
|
- **自动签到**:Cron 定时执行,智能解析奖励金额,签到失败自动通知
|
||||||
- **余额管理**:定时刷新,收入追踪,余额兜底估算,凭证过期自动重登录
|
- **余额管理**:定时刷新,收入追踪,余额兜底估算,凭证过期自动重登录
|
||||||
- **告警通知**:支持 Webhook / Bark / Server酱 / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景
|
- **告警通知**:支持 Webhook / Bark / Server酱 / Telegram Bot / SMTP 邮件,覆盖余额不足、站点异常、签到失败等场景
|
||||||
- **数据看板**:余额分布饼图、消费趋势图表、全局搜索、代理请求日志
|
- **数据看板**:余额分布饼图、消费趋势图表、全局搜索、代理请求日志
|
||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|||||||
+3
-2
@@ -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.
|
||||||
|
|||||||
@@ -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 * * *
|
||||||
|
|||||||
@@ -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 邮件
|
||||||
|
|
||||||
| 变量名 | 说明 | 默认值 |
|
| 变量名 | 说明 | 默认值 |
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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:');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 &&
|
||||||
|
|||||||
@@ -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 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user