diff --git a/apps/api/src/routes/settings.test.ts b/apps/api/src/routes/settings.test.ts index 0492adf..d1e1a8c 100644 --- a/apps/api/src/routes/settings.test.ts +++ b/apps/api/src/routes/settings.test.ts @@ -10,8 +10,9 @@ describe('settings telegram test', () => { beforeEach(async () => { resetTestDb() settingsRepository.upsert('settings-main', { - telegramBotToken: 'token', + telegramBotToken: 'db-token', telegramChatId: '123', + telegramMessageThreadId: '99', }) app = await buildApp() }) @@ -22,7 +23,7 @@ describe('settings telegram test', () => { closeDb() }) - it('returns telegram API error', async () => { + it('returns telegram API error with hint', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => @@ -34,5 +35,49 @@ describe('settings telegram test', () => { const body = res.json() as { ok: boolean; error?: string } expect(body.ok).toBe(false) expect(body.error).toContain('chat not found') + expect(body.error).toContain('Chat ID') + }) + + it('uses body overrides and falls back to db token', async () => { + const fetchMock = vi.fn(async () => Response.json({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + const res = await app.inject({ + method: 'POST', + url: '/api/settings/telegram/test', + payload: { + telegramChatId: '-100999', + telegramMessageThreadId: '42', + }, + }) + expect(res.statusCode).toBe(200) + const body = res.json() as { ok: boolean } + expect(body.ok).toBe(true) + + const call = fetchMock.mock.calls[0] as [string, RequestInit] | undefined + expect(call).toBeDefined() + const sent = JSON.parse(String(call![1].body)) as { + chat_id: string + message_thread_id: number + } + expect(sent.chat_id).toBe('-100999') + expect(sent.message_thread_id).toBe(42) + }) + + it('uses body token when provided', async () => { + const fetchMock = vi.fn(async () => Response.json({ ok: true })) + vi.stubGlobal('fetch', fetchMock) + + await app.inject({ + method: 'POST', + url: '/api/settings/telegram/test', + payload: { + telegramBotToken: 'override-token', + telegramChatId: '-1001', + }, + }) + + const url = String((fetchMock.mock.calls[0] as [string])[0]) + expect(url).toContain('botoverride-token/') }) }) diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 3f4c210..e0f6359 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -1,6 +1,6 @@ import type { FastifyPluginAsync } from 'fastify' import { settingsRepository } from '@cfdm/db/repositories/settings' -import { settingsSchema } from '@cfdm/shared/contracts/settings' +import { settingsSchema, telegramTestBodySchema } from '@cfdm/shared/contracts/settings' import { restartScheduler } from '../services/scheduler.js' import { sendTelegramMessage } from '../services/telegram.js' @@ -30,16 +30,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => { return result }) - app.post('/api/settings/telegram/test', async () => { + app.post('/api/settings/telegram/test', async (req) => { + const parsed = telegramTestBodySchema.safeParse(req.body ?? {}) + const body = parsed.success ? parsed.data : {} const settings = settingsRepository.getRow('settings-main') - if (!settings?.telegramBotToken?.trim() || !settings.telegramChatId?.trim()) { + + const token = body.telegramBotToken?.trim() || settings?.telegramBotToken?.trim() || '' + const chatId = body.telegramChatId?.trim() || settings?.telegramChatId?.trim() || '' + const messageThreadId = + body.telegramMessageThreadId !== undefined + ? body.telegramMessageThreadId + : settings?.telegramMessageThreadId + + if (!token || !chatId) { return { ok: false, error: 'Укажите токен бота и chat ID в настройках' } } const result = await sendTelegramMessage( - settings.telegramBotToken, - settings.telegramChatId, + token, + chatId, '✅ VPS Tracker: тестовое сообщение', - settings.telegramMessageThreadId, + messageThreadId, ) return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' } }) diff --git a/apps/api/src/services/telegram.test.ts b/apps/api/src/services/telegram.test.ts new file mode 100644 index 0000000..1a1587f --- /dev/null +++ b/apps/api/src/services/telegram.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { formatTelegramApiError, telegramErrorHint } from './telegram.js' + +describe('telegramErrorHint', () => { + it('maps thread not found', () => { + expect(telegramErrorHint('Bad Request: message thread not found')).toContain('Thread ID') + }) + + it('maps chat not found', () => { + expect(telegramErrorHint('Bad Request: chat not found')).toContain('Chat ID') + }) + + it('returns null for unknown errors', () => { + expect(telegramErrorHint('Something else')).toBeNull() + }) +}) + +describe('formatTelegramApiError', () => { + it('includes hint for known telegram description', () => { + const msg = formatTelegramApiError( + '-1001', + { status: 400, statusText: 'Bad Request' }, + { ok: false, description: 'Bad Request: message thread not found' }, + ) + expect(msg).toContain('message thread not found') + expect(msg).toContain('Thread ID') + }) + + it('falls back to raw body when JSON has no description', () => { + const msg = formatTelegramApiError( + '-1001', + { status: 400, statusText: 'Bad Request' }, + {}, + 'invalid payload', + ) + expect(msg).toBe('-1001: invalid payload') + }) +}) diff --git a/apps/api/src/services/telegram.ts b/apps/api/src/services/telegram.ts index da04edb..7230f15 100644 --- a/apps/api/src/services/telegram.ts +++ b/apps/api/src/services/telegram.ts @@ -7,6 +7,49 @@ export interface TelegramSendResult { error?: string } +interface TelegramApiResponse { + ok?: boolean + description?: string + error_code?: number +} + +/** Маппинг частых ошибок Telegram API на подсказки (для тестов и UI). */ +export function telegramErrorHint(description: string): string | null { + const d = description.toLowerCase() + if (d.includes('message thread not found')) { + return 'Проверьте Thread ID и что в группе включены топики' + } + if (d.includes('chat not found')) { + return 'Бот не добавлен в чат или неверный Chat ID' + } + if (d.includes('not enough rights')) { + return 'Дайте боту право отправлять сообщения (администратор в группе)' + } + if (d.includes('unauthorized')) { + return 'Неверный токен бота' + } + if (d.includes('bot was blocked')) { + return 'Пользователь заблокировал бота' + } + return null +} + +export function formatTelegramApiError( + chatId: string, + res: Pick, + data: TelegramApiResponse, + rawBody?: string, +): string { + const description = data.description?.trim() + if (description) { + const hint = telegramErrorHint(description) + return hint ? `${chatId}: ${description} — ${hint}` : `${chatId}: ${description}` + } + const snippet = rawBody?.trim().slice(0, 200) + const fallback = snippet || res.statusText || `HTTP ${res.status}` + return `${chatId}: ${fallback}` +} + export async function sendTelegramMessage( token: string, chatIds: string | string[], @@ -43,12 +86,18 @@ export async function sendTelegramMessage( headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, chat_id: chatId }), }) - const data = (await res.json().catch(() => ({}))) as { ok?: boolean; description?: string } + const rawBody = await res.text() + let data: TelegramApiResponse = {} + try { + data = JSON.parse(rawBody) as TelegramApiResponse + } catch { + /* non-JSON body */ + } if (data.ok) { anyOk = true } else { - const err = data.description || res.statusText || 'Unknown error' - errors.push(`${chatId}: ${err}`) + const err = formatTelegramApiError(chatId, res, data, rawBody) + errors.push(err) console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err) } } catch (err) { diff --git a/apps/web/src/components/global-search.tsx b/apps/web/src/components/global-search.tsx index 84b1d43..5a22a07 100644 --- a/apps/web/src/components/global-search.tsx +++ b/apps/web/src/components/global-search.tsx @@ -43,61 +43,85 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) { const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot]) return ( - - + + - + Ничего не найдено - go('/dashboard')}> - - Дашборд - - go('/vps')}> - - Все VPS - + go('/dashboard')}> + + Дашборд + + go('/vps')}> + + Все VPS + - {vpsItems.slice(0, 50).map((v) => ( - go('/vps/$vpsId', { vpsId: v.id })}> - - {v.ip || v.dns || v.id} - {v.project ? · {v.project} : null} - - ))} - - - {accountItems.map((a) => ( - go('/accounts')} - > - - {a.name} - - ))} - - - {projectItems.map((p) => { - const row = p as { id: string; name: string } - return ( - go('/vps', { project: row.name })}> - - {row.name} + {vpsItems.slice(0, 50).map((v) => ( + go('/vps/$vpsId', { vpsId: v.id })} + > + + {v.ip || v.dns || v.id} + {v.project ? ( + {v.project} + ) : null} - ) - })} + ))} + + + {accountItems.map((a) => ( + go('/accounts')} + > + + {a.name} + {providerById.get(a.providerId)?.name ? ( + + {providerById.get(a.providerId)?.name} + + ) : null} + + ))} + + + + {projectItems.map((p) => { + const row = p as { id: string; name: string } + return ( + go('/vps', { project: row.name })} + > + + {row.name} + + ) + })} + + - {(snapshot?.providers ?? []).map((p) => ( - go('/providers')}> - - {p.name} - - ))} + {(snapshot?.providers ?? []).map((p) => ( + go('/providers')}> + + {p.name} + + ))} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 48d421e..b1cc500 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -108,8 +108,15 @@ export const api = { }), fetchSyncStatus: () => fetchApi('/api/sync/status'), - sendTelegramTest: () => - fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { method: 'POST' }), + sendTelegramTest: (body?: { + telegramBotToken?: string + telegramChatId?: string + telegramMessageThreadId?: string + }) => + fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { + method: 'POST', + body: JSON.stringify(body ?? {}), + }), sendWebhookTest: () => fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }), diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx index 5c32556..5c13e1f 100644 --- a/apps/web/src/routes/_auth/settings.tsx +++ b/apps/web/src/routes/_auth/settings.tsx @@ -42,7 +42,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues { syncIntervalMinutes: s.syncIntervalMinutes ?? 60, syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440, telegramChatId: s.telegramChatId ?? '', - telegramBotToken: s.telegramBotToken ?? '', + telegramBotToken: '', notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false, notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false, notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== false, @@ -57,6 +57,26 @@ function settingsToFormValues(s: Settings): SettingsFormValues { } } +function buildSettingsSavePayload(r: SettingsFormValues): SettingsFormValues { + const { telegramBotToken, ...rest } = r + const token = telegramBotToken?.trim() ?? '' + return token ? { ...rest, telegramBotToken: token } : (rest as SettingsFormValues) +} + +function buildTelegramTestPayload(values: SettingsFormValues) { + const token = values.telegramBotToken?.trim() ?? '' + const payload: { + telegramChatId?: string + telegramMessageThreadId?: string + telegramBotToken?: string + } = { + telegramChatId: values.telegramChatId?.trim() || undefined, + telegramMessageThreadId: values.telegramMessageThreadId ?? '', + } + if (token) payload.telegramBotToken = token + return payload +} + function BoolSelect({ id, label, @@ -96,7 +116,7 @@ function SettingsPage() { const upsertMut = useMutation({ mutationFn: (patch: SettingsFormValues) => { - const payload = { ...patch } + const payload = buildSettingsSavePayload(patch) if (current?.id) return api.update('settings', current.id, payload) return api.create('settings', { id: 'settings-main', @@ -114,15 +134,15 @@ function SettingsPage() { }) const telegramTestMut = useMutation({ - mutationFn: () => api.sendTelegramTest(), + mutationFn: () => api.sendTelegramTest(buildTelegramTestPayload(form.getValues())), onSuccess: (data) => { if (!data.ok) { - toast.error(data.error ?? 'Ошибка Telegram') + toast.error(data.error ?? 'Ошибка Telegram', { duration: 10_000 }) return } toast.success('Тестовое сообщение отправлено') }, - onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки'), + onError: (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Ошибка отправки', { duration: 10_000 }), }) const webhookTestMut = useMutation({ @@ -319,7 +339,10 @@ function SettingsPage() { @@ -465,10 +488,16 @@ function SettingsPage() { Событие Канал Статус + Ошибка - {notificationRows.map((row) => ( + {notificationRows.map((row) => { + const errorText = + row.status === 'failed' && row.payload?.error != null + ? String(row.payload.error) + : '' + return ( {new Date(row.createdAt).toLocaleString('ru-RU')} @@ -476,8 +505,12 @@ function SettingsPage() { {row.event} {row.channel} {row.status} + + {errorText || '—'} + - ))} + ) + })} diff --git a/packages/db/src/repositories/settings.test.ts b/packages/db/src/repositories/settings.test.ts new file mode 100644 index 0000000..e7c99c3 --- /dev/null +++ b/packages/db/src/repositories/settings.test.ts @@ -0,0 +1,33 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { settingsRepository } from './settings.js' +import { resetTestDb } from '../test-setup.js' + +describe('settingsRepository', () => { + beforeEach(() => { + resetTestDb() + }) + + it('preserves telegram token on update when token empty', () => { + settingsRepository.upsert('settings-main', { + telegramBotToken: 'secret-token', + telegramChatId: '-100123', + }) + const updated = settingsRepository.upsert('settings-main', { + telegramBotToken: '', + syncIntervalMinutes: 30, + }) + expect(updated.telegramBotTokenSet).toBe(true) + expect(settingsRepository.getRow('settings-main')?.telegramBotToken).toBe('secret-token') + expect(updated.syncIntervalMinutes).toBe(30) + }) + + it('replaces telegram token when new value provided', () => { + settingsRepository.upsert('settings-main', { + telegramBotToken: 'old-token', + }) + settingsRepository.upsert('settings-main', { + telegramBotToken: 'new-token', + }) + expect(settingsRepository.getRow('settings-main')?.telegramBotToken).toBe('new-token') + }) +}) diff --git a/packages/db/src/repositories/settings.ts b/packages/db/src/repositories/settings.ts index bd33451..ef6b873 100644 --- a/packages/db/src/repositories/settings.ts +++ b/packages/db/src/repositories/settings.ts @@ -95,7 +95,9 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) { ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) : existing?.syncTariffsIntervalMinutes ?? 1440, telegramBotToken: - r.telegramBotToken !== undefined ? r.telegramBotToken || '' : existing?.telegramBotToken ?? '', + r.telegramBotToken !== undefined && String(r.telegramBotToken || '').trim() !== '' + ? r.telegramBotToken + : existing?.telegramBotToken ?? '', telegramChatId: r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '', telegramMessageThreadId: diff --git a/packages/shared/src/contracts/settings.ts b/packages/shared/src/contracts/settings.ts index 913305c..529e60e 100644 --- a/packages/shared/src/contracts/settings.ts +++ b/packages/shared/src/contracts/settings.ts @@ -26,3 +26,11 @@ export const settingsSchema = z.object({ }) export type Settings = z.infer + +export const telegramTestBodySchema = z.object({ + telegramBotToken: z.string().optional(), + telegramChatId: z.string().optional(), + telegramMessageThreadId: z.string().optional(), +}) + +export type TelegramTestBody = z.infer diff --git a/packages/ui/src/components/command.tsx b/packages/ui/src/components/command.tsx index 4d0b745..114f0fd 100644 --- a/packages/ui/src/components/command.tsx +++ b/packages/ui/src/components/command.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { Command as CommandPrimitive } from "cmdk" diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index e4648d2..0bca243 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,5 +1,3 @@ -"use client" - import * as React from "react" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx index fd8ee87..1e1f7cd 100644 --- a/packages/ui/src/components/input-group.tsx +++ b/packages/ui/src/components/input-group.tsx @@ -1,3 +1,5 @@ +"use client" + import * as React from "react" import { cva, type VariantProps } from "class-variance-authority"