1
0
forked from boos/BoosAPI

fix: improve tester timeouts and telegram proxy config

This commit is contained in:
ciat-777
2026-03-14 16:18:43 +08:00
parent 201a806e51
commit 7bd48a3c6f
12 changed files with 369 additions and 6 deletions
+3 -2
View File
@@ -176,8 +176,9 @@ Metapi 的路由引擎按多因子加权选择最优通道。
- **个人聊天**:向你的 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. **测试**:保存后点击页面上的「测试通知」按钮验证是否收到消息
3. **填入配置**:将获取的 Token 和 Chat ID 填入管理后台「通知设置」页面
4. **大陆服务器反代**:如果服务器无法直连 Telegram,可在「通知设置」里填写 `Telegram API Base URL`,例如 `https://your-proxy.example.com`,Metapi 将请求 `https://your-proxy.example.com/bot<token>/sendMessage`
5. **测试**:保存后点击页面上的「测试通知」按钮验证是否收到消息
### SMTP 邮件
+6
View File
@@ -32,6 +32,12 @@ describe('buildConfig', () => {
expect(config.listenHost).toBe('127.0.0.1');
});
it('defaults telegram api base url to the official endpoint', () => {
const config = buildConfig({});
expect(config.telegramApiBaseUrl).toBe('https://api.telegram.org');
});
it('accepts JSON request bodies larger than Fastify default 1 MiB', async () => {
const app = Fastify(buildFastifyOptions(buildConfig({})));
const largeText = 'a'.repeat(2 * 1024 * 1024);
+1
View File
@@ -61,6 +61,7 @@ export function buildConfig(env: NodeJS.ProcessEnv) {
serverChanEnabled: parseBoolean(env.SERVERCHAN_ENABLED, true),
serverChanKey: env.SERVERCHAN_KEY || '',
telegramEnabled: parseBoolean(env.TELEGRAM_ENABLED, false),
telegramApiBaseUrl: 'https://api.telegram.org',
telegramBotToken: env.TELEGRAM_BOT_TOKEN || '',
telegramChatId: env.TELEGRAM_CHAT_ID || '',
smtpEnabled: parseBoolean(env.SMTP_ENABLED, false),
+5
View File
@@ -175,6 +175,11 @@ function applyRuntimeSettings(settingsMap: Map<string, string>) {
const telegramEnabled = parseSettingFromMap<boolean>(settingsMap, 'telegram_enabled');
if (typeof telegramEnabled === 'boolean') config.telegramEnabled = telegramEnabled;
const telegramApiBaseUrl = parseSettingFromMap<string>(settingsMap, 'telegram_api_base_url');
if (typeof telegramApiBaseUrl === 'string' && telegramApiBaseUrl.trim()) {
config.telegramApiBaseUrl = telegramApiBaseUrl.trim().replace(/\/+$/, '');
}
const telegramBotToken = parseSettingFromMap<string>(settingsMap, 'telegram_bot_token');
if (typeof telegramBotToken === 'string') config.telegramBotToken = telegramBotToken;
@@ -49,6 +49,10 @@ describe('settings and auth events', () => {
config.logCleanupProgramLogsEnabled = false;
config.logCleanupRetentionDays = 30;
config.routingFallbackUnitCost = 1;
(config as any).telegramEnabled = false;
(config as any).telegramApiBaseUrl = 'https://api.telegram.org';
(config as any).telegramBotToken = '';
(config as any).telegramChatId = '';
});
afterAll(async () => {
@@ -170,6 +174,49 @@ describe('settings and auth events', () => {
expect(body.message).toContain('Telegram Chat ID');
});
it('persists and returns telegram api base url from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
telegramApiBaseUrl: 'https://tg-proxy.example.com/custom/',
},
});
expect(updateResponse.statusCode).toBe(200);
const updated = updateResponse.json() as { telegramApiBaseUrl?: string };
expect(updated.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom');
expect((config as any).telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom');
const saved = await db.select().from(schema.settings).where(eq(schema.settings.key, 'telegram_api_base_url')).get();
expect(saved?.value).toBe(JSON.stringify('https://tg-proxy.example.com/custom'));
const getResponse = await app.inject({
method: 'GET',
url: '/api/settings/runtime',
});
expect(getResponse.statusCode).toBe(200);
const runtime = getResponse.json() as { telegramApiBaseUrl?: string };
expect(runtime.telegramApiBaseUrl).toBe('https://tg-proxy.example.com/custom');
});
it('rejects invalid telegram api base url when telegram is enabled', async () => {
const response = await app.inject({
method: 'PUT',
url: '/api/settings/runtime',
payload: {
telegramEnabled: true,
telegramBotToken: '123456:telegram-token',
telegramChatId: '-1001234567890',
telegramApiBaseUrl: 'not-a-url',
},
});
expect(response.statusCode).toBe(400);
const body = response.json() as { message?: string };
expect(body.message).toContain('Telegram API Base URL');
});
it('persists and returns routing fallback unit cost from runtime settings', async () => {
const updateResponse = await app.inject({
method: 'PUT',
+30
View File
@@ -40,6 +40,7 @@ interface RuntimeSettingsBody {
serverChanEnabled?: boolean;
serverChanKey?: string;
telegramEnabled?: boolean;
telegramApiBaseUrl?: string;
telegramBotToken?: string;
telegramChatId?: string;
smtpEnabled?: boolean;
@@ -137,6 +138,10 @@ function isValidHttpUrl(raw: string): boolean {
}
}
function normalizeTelegramApiBaseUrl(raw: string): string {
return String(raw || '').trim().replace(/\/+$/, '');
}
function applyImportedSettingToRuntime(key: string, value: unknown) {
switch (key) {
case 'checkin_cron': {
@@ -223,6 +228,11 @@ function applyImportedSettingToRuntime(key: string, value: unknown) {
config.telegramEnabled = !!value;
return;
}
case 'telegram_api_base_url': {
if (typeof value !== 'string') return;
config.telegramApiBaseUrl = normalizeTelegramApiBaseUrl(value) || 'https://api.telegram.org';
return;
}
case 'telegram_bot_token': {
if (typeof value !== 'string') return;
config.telegramBotToken = value.trim();
@@ -322,6 +332,7 @@ function getRuntimeSettingsResponse(currentAdminIp = '') {
serverChanEnabled: config.serverChanEnabled,
serverChanKeyMasked: maskSecret(config.serverChanKey),
telegramEnabled: config.telegramEnabled,
telegramApiBaseUrl: config.telegramApiBaseUrl,
telegramBotTokenMasked: maskSecret(config.telegramBotToken),
telegramChatId: config.telegramChatId,
smtpEnabled: config.smtpEnabled,
@@ -452,11 +463,15 @@ export async function settingsRoutes(app: FastifyInstance) {
}
const telegramTouched = body.telegramEnabled !== undefined
|| body.telegramApiBaseUrl !== undefined
|| body.telegramBotToken !== undefined
|| body.telegramChatId !== undefined;
const nextTelegramEnabled = body.telegramEnabled !== undefined
? !!body.telegramEnabled
: config.telegramEnabled;
const nextTelegramApiBaseUrl = body.telegramApiBaseUrl !== undefined
? normalizeTelegramApiBaseUrl(body.telegramApiBaseUrl)
: config.telegramApiBaseUrl;
const nextTelegramBotToken = body.telegramBotToken !== undefined
? String(body.telegramBotToken || '').trim()
: config.telegramBotToken;
@@ -473,6 +488,11 @@ export async function settingsRoutes(app: FastifyInstance) {
if (!nextTelegramChatId) {
return reply.code(400).send({ success: false, message: 'Telegram Chat ID 不能为空(启用 Telegram 时)' });
}
if (nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
}
} else if (body.telegramApiBaseUrl !== undefined && nextTelegramApiBaseUrl && !isValidHttpUrl(nextTelegramApiBaseUrl)) {
return reply.code(400).send({ success: false, message: 'Telegram API Base URL 无效,请填写 http/https 地址' });
}
if (body.checkinCron !== undefined) {
@@ -639,6 +659,16 @@ export async function settingsRoutes(app: FastifyInstance) {
upsertSetting('telegram_enabled', config.telegramEnabled);
}
if (body.telegramApiBaseUrl !== undefined) {
const normalizedTelegramApiBaseUrl = normalizeTelegramApiBaseUrl(body.telegramApiBaseUrl);
const nextTelegramApiBaseUrl = normalizedTelegramApiBaseUrl || 'https://api.telegram.org';
if (nextTelegramApiBaseUrl !== config.telegramApiBaseUrl) {
changedLabels.push('Telegram API Base URL');
}
config.telegramApiBaseUrl = nextTelegramApiBaseUrl;
upsertSetting('telegram_api_base_url', config.telegramApiBaseUrl);
}
if (body.telegramBotToken !== undefined) {
if (String(body.telegramBotToken || '').trim() !== config.telegramBotToken) {
changedLabels.push('Telegram Bot Token');
+24
View File
@@ -198,4 +198,28 @@ describe('notifyService', () => {
expect(payload.text || '').toContain('Local Time:');
expect(payload.text || '').toContain('UTC Time:');
});
it('uses TELEGRAM_API_BASE_URL when configured', 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 as any).telegramApiBaseUrl = 'https://tg-proxy.example.com/custom/';
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).toHaveBeenCalledWith(
'https://tg-proxy.example.com/custom/bot123456:telegram-token/sendMessage',
expect.objectContaining({
method: 'POST',
}),
);
});
});
+2 -1
View File
@@ -218,7 +218,8 @@ export async function sendNotification(
}
if (config.telegramEnabled && config.telegramBotToken && config.telegramChatId) {
const telegramApiUrl = `https://api.telegram.org/bot${config.telegramBotToken}/sendMessage`;
const telegramApiBaseUrl = String(config.telegramApiBaseUrl || 'https://api.telegram.org').replace(/\/+$/, '');
const telegramApiUrl = `${telegramApiBaseUrl}/bot${config.telegramBotToken}/sendMessage`;
const text = buildTelegramText(title, resolvedMessage, level, timeFootnote);
tasks.push({
channel: 'telegram',
+100
View File
@@ -0,0 +1,100 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { api, type ProxyTestRequestEnvelope } from './api.js';
import { persistAuthSession } from './authSession.js';
function createMemoryStorage() {
const store = new Map<string, string>();
return {
getItem(key: string) {
return store.has(key) ? store.get(key)! : null;
},
setItem(key: string, value: string) {
store.set(key, value);
},
removeItem(key: string) {
store.delete(key);
},
};
}
function installPendingFetch() {
const fetchMock = vi.fn((_input: RequestInfo | URL, init?: RequestInit) => new Promise<Response>((_resolve, reject) => {
const signal = init?.signal;
if (!signal) return;
if (signal.aborted) {
reject(new DOMException('Aborted', 'AbortError'));
return;
}
signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { once: true });
}));
vi.stubGlobal('fetch', fetchMock);
return fetchMock;
}
describe('api proxy test timeout handling', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.stubGlobal('localStorage', createMemoryStorage());
persistAuthSession(globalThis.localStorage as Storage, 'token-1');
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('keeps image generation proxy tests alive past the default 30 second timeout', async () => {
installPendingFetch();
const payload: ProxyTestRequestEnvelope = {
method: 'POST',
path: '/v1/images/generations',
requestKind: 'json',
jsonBody: {
model: 'gemini-imagen',
prompt: 'banana cat',
},
};
let settled = false;
const promise = api.proxyTest(payload);
const handled = promise
.then(() => ({ ok: true as const }))
.catch((error: Error) => ({ ok: false as const, error }))
.finally(() => {
settled = true;
});
await vi.advanceTimersByTimeAsync(30_000);
expect(settled).toBe(false);
await vi.advanceTimersByTimeAsync(120_000);
const result = await handled;
expect(result.ok).toBe(false);
if (result.ok) {
throw new Error('Expected image generation proxy test to time out');
}
expect(result.error.message).toBe('请求超时(150s)');
});
it('still uses the default 30 second timeout for generic proxy tests', async () => {
installPendingFetch();
const payload: ProxyTestRequestEnvelope = {
method: 'POST',
path: '/v1/embeddings',
requestKind: 'json',
jsonBody: {
model: 'text-embedding-3-small',
input: 'hello',
},
};
const promise = api.proxyTest(payload).catch((error: Error) => error);
await vi.advanceTimersByTimeAsync(30_000);
await expect(promise).resolves.toMatchObject({ message: '请求超时(30s)' });
});
});
+26 -3
View File
@@ -135,6 +135,17 @@ export type ProxyTestRequestEnvelope = {
multipartFiles?: ProxyTestMultipartFile[];
};
const DEFAULT_PROXY_TEST_TIMEOUT_MS = 30_000;
const LONG_RUNNING_PROXY_TEST_TIMEOUT_MS = 150_000;
function resolveProxyTestTimeoutMs(data: ProxyTestRequestEnvelope) {
if (data.jobMode) return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
if (data.path === '/v1/images/generations') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
if (data.path === '/v1/images/edits') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
if (data.path === '/v1/videos' && data.method === 'POST') return LONG_RUNNING_PROXY_TEST_TIMEOUT_MS;
return DEFAULT_PROXY_TEST_TIMEOUT_MS;
}
export type ProxyTestJobResponse = {
jobId: string;
status: 'pending' | 'succeeded' | 'failed' | 'cancelled';
@@ -425,13 +436,25 @@ export const api = {
getTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`),
deleteTestChatJob: (jobId: string) => request(`/api/test/chat/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
startProxyTestJob: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy/jobs', { method: 'POST', body: JSON.stringify(data) }),
request('/api/test/proxy/jobs', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
getProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`),
deleteProxyTestJob: (jobId: string) => request(`/api/test/proxy/jobs/${encodeURIComponent(jobId)}`, { method: 'DELETE' }),
testProxy: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
request('/api/test/proxy', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
proxyTest: (data: ProxyTestRequestEnvelope) =>
request('/api/test/proxy', { method: 'POST', body: JSON.stringify(data) }),
request('/api/test/proxy', {
method: 'POST',
body: JSON.stringify(data),
timeoutMs: resolveProxyTestTimeoutMs(data),
}),
testChat: (data: TestChatRequestPayload) =>
request('/api/test/chat', { method: 'POST', body: JSON.stringify(data) }),
testProxyStream: async (data: ProxyTestRequestEnvelope, signal?: AbortSignal) => {
+108
View File
@@ -0,0 +1,108 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act, create, type ReactTestInstance } from 'react-test-renderer';
import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from '../components/Toast.js';
import NotificationSettings from './NotificationSettings.js';
const { apiMock } = vi.hoisted(() => ({
apiMock: {
getRuntimeSettings: vi.fn(),
updateRuntimeSettings: vi.fn(),
testNotification: vi.fn(),
},
}));
vi.mock('../api.js', () => ({
api: apiMock,
}));
function collectText(node: ReactTestInstance): string {
return (node.children || []).map((child) => {
if (typeof child === 'string') return child;
return collectText(child);
}).join('');
}
async function flushMicrotasks() {
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
}
describe('NotificationSettings', () => {
beforeEach(() => {
vi.clearAllMocks();
apiMock.getRuntimeSettings.mockResolvedValue({
webhookUrl: '',
barkUrl: '',
webhookEnabled: true,
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: true,
telegramApiBaseUrl: 'https://tg-proxy.example.com',
telegramChatId: '-1001234567890',
telegramBotTokenMasked: '1234****token',
smtpEnabled: false,
smtpHost: '',
smtpPort: 587,
smtpSecure: false,
smtpUser: '',
smtpFrom: '',
smtpTo: '',
notifyCooldownSec: 300,
});
apiMock.updateRuntimeSettings.mockResolvedValue({
success: true,
telegramApiBaseUrl: 'https://proxy.example.com/custom',
telegramBotTokenMasked: '1234****token',
});
apiMock.testNotification.mockResolvedValue({ success: true });
});
afterEach(() => {
vi.clearAllMocks();
});
it('loads and saves telegram api base url', async () => {
let root: ReturnType<typeof create> | null = null;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<NotificationSettings />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const proxyInput = root.root.find((node) => (
node.type === 'input'
&& node.props.placeholder === '例如: https://your-proxy.example.com'
));
expect(proxyInput.props.value).toBe('https://tg-proxy.example.com');
await act(async () => {
proxyInput.props.onChange({ target: { value: 'https://proxy.example.com/custom/' } });
});
const saveButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('保存通知设置')
));
await act(async () => {
saveButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.updateRuntimeSettings).toHaveBeenCalledWith(expect.objectContaining({
telegramApiBaseUrl: 'https://proxy.example.com/custom/',
}));
} finally {
root?.unmount();
}
});
});
+17
View File
@@ -10,6 +10,7 @@ type RuntimeSettings = {
barkEnabled: boolean;
serverChanEnabled: boolean;
telegramEnabled: boolean;
telegramApiBaseUrl: string;
telegramChatId: string;
smtpEnabled: boolean;
smtpHost: string;
@@ -32,6 +33,7 @@ export default function NotificationSettings() {
barkEnabled: true,
serverChanEnabled: false,
telegramEnabled: false,
telegramApiBaseUrl: 'https://api.telegram.org',
telegramChatId: '',
smtpEnabled: false,
smtpHost: '',
@@ -74,6 +76,7 @@ export default function NotificationSettings() {
barkEnabled: runtimeInfo.barkEnabled ?? true,
serverChanEnabled: !!runtimeInfo.serverChanEnabled,
telegramEnabled: !!runtimeInfo.telegramEnabled,
telegramApiBaseUrl: runtimeInfo.telegramApiBaseUrl || 'https://api.telegram.org',
telegramChatId: runtimeInfo.telegramChatId || '',
smtpEnabled: !!runtimeInfo.smtpEnabled,
smtpHost: runtimeInfo.smtpHost || '',
@@ -110,6 +113,7 @@ export default function NotificationSettings() {
barkEnabled: runtime.barkEnabled,
serverChanEnabled: runtime.serverChanEnabled,
telegramEnabled: runtime.telegramEnabled,
telegramApiBaseUrl: runtime.telegramApiBaseUrl,
telegramChatId: runtime.telegramChatId,
smtpEnabled: runtime.smtpEnabled,
smtpHost: runtime.smtpHost,
@@ -325,6 +329,19 @@ export default function NotificationSettings() {
</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 style={{ gridColumn: '1 / -1' }}>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram API Base URL</div>
<input
value={runtime.telegramApiBaseUrl}
onChange={(e) => setRuntime((prev) => ({ ...prev, telegramApiBaseUrl: e.target.value }))}
placeholder="例如: https://your-proxy.example.com"
style={inputStyle}
disabled={!runtime.telegramEnabled}
/>
<div style={{ marginTop: 8, fontSize: 12, color: 'var(--color-text-muted)' }}>
留空或使用默认值时直连官方 Telegram API;如需国内反代,可填写反代前缀。
</div>
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 500, marginBottom: 8, color: 'var(--color-text-secondary)' }}>Telegram Chat ID</div>
<input