feat: 添加 Telegram 通知支持,更新配置和文档
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -88,6 +88,15 @@ try {
|
||||
const serverChanKey = parseSetting<string>('serverchan_key');
|
||||
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');
|
||||
if (typeof smtpEnabled === 'boolean') config.smtpEnabled = smtpEnabled;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 开关');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
Reference in New Issue
Block a user