feat(api, web): тест Telegram из формы, подсказки ошибок API и UX настроек
Docker / build (push) Has been cancelled

Тест отправки использует значения формы без предварительного сохранения; пустой токен при сохранении не затирает сохранённый. Добавлены подсказки по частым ошибкам Telegram API и колонка ошибок в журнале уведомлений.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-06-29 00:54:51 +07:00
co-authored by Cursor
parent 7f91bc3624
commit 05e7bf829e
13 changed files with 319 additions and 72 deletions
+47 -2
View File
@@ -10,8 +10,9 @@ describe('settings telegram test', () => {
beforeEach(async () => { beforeEach(async () => {
resetTestDb() resetTestDb()
settingsRepository.upsert('settings-main', { settingsRepository.upsert('settings-main', {
telegramBotToken: 'token', telegramBotToken: 'db-token',
telegramChatId: '123', telegramChatId: '123',
telegramMessageThreadId: '99',
}) })
app = await buildApp() app = await buildApp()
}) })
@@ -22,7 +23,7 @@ describe('settings telegram test', () => {
closeDb() closeDb()
}) })
it('returns telegram API error', async () => { it('returns telegram API error with hint', async () => {
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn(async () => vi.fn(async () =>
@@ -34,5 +35,49 @@ describe('settings telegram test', () => {
const body = res.json() as { ok: boolean; error?: string } const body = res.json() as { ok: boolean; error?: string }
expect(body.ok).toBe(false) expect(body.ok).toBe(false)
expect(body.error).toContain('chat not found') 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/')
}) })
}) })
+16 -6
View File
@@ -1,6 +1,6 @@
import type { FastifyPluginAsync } from 'fastify' import type { FastifyPluginAsync } from 'fastify'
import { settingsRepository } from '@cfdm/db/repositories/settings' 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 { restartScheduler } from '../services/scheduler.js'
import { sendTelegramMessage } from '../services/telegram.js' import { sendTelegramMessage } from '../services/telegram.js'
@@ -30,16 +30,26 @@ export const settingsRoutes: FastifyPluginAsync = async (app) => {
return result 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') 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 в настройках' } return { ok: false, error: 'Укажите токен бота и chat ID в настройках' }
} }
const result = await sendTelegramMessage( const result = await sendTelegramMessage(
settings.telegramBotToken, token,
settings.telegramChatId, chatId,
'✅ VPS Tracker: тестовое сообщение', '✅ VPS Tracker: тестовое сообщение',
settings.telegramMessageThreadId, messageThreadId,
) )
return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' } return result.ok ? { ok: true } : { ok: false, error: result.error ?? 'Ошибка Telegram API' }
}) })
+38
View File
@@ -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')
})
})
+52 -3
View File
@@ -7,6 +7,49 @@ export interface TelegramSendResult {
error?: string 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<Response, 'status' | 'statusText'>,
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( export async function sendTelegramMessage(
token: string, token: string,
chatIds: string | string[], chatIds: string | string[],
@@ -43,12 +86,18 @@ export async function sendTelegramMessage(
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, chat_id: chatId }), 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) { if (data.ok) {
anyOk = true anyOk = true
} else { } else {
const err = data.description || res.statusText || 'Unknown error' const err = formatTelegramApiError(chatId, res, data, rawBody)
errors.push(`${chatId}: ${err}`) errors.push(err)
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err) console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err)
} }
} catch (err) { } catch (err) {
+70 -46
View File
@@ -43,61 +43,85 @@ export function GlobalSearch({ open, onOpenChange }: GlobalSearchProps) {
const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot]) const projectItems = useMemo(() => snapshot?.serverProjects ?? [], [snapshot])
return ( return (
<CommandDialog open={open} onOpenChange={onOpenChange} title="Поиск" description="VPS, аккаунты, проекты и навигация"> <CommandDialog
<Command> open={open}
onOpenChange={onOpenChange}
title="Поиск"
description="VPS, аккаунты, проекты и навигация"
className="sm:max-w-lg"
>
<Command className="**:data-[selected=true]:bg-muted **:data-selected:bg-transparent">
<CommandInput placeholder="IP, DNS, проект, аккаунт…" /> <CommandInput placeholder="IP, DNS, проект, аккаунт…" />
<CommandList> <CommandList className="max-h-96">
<CommandEmpty>Ничего не найдено</CommandEmpty> <CommandEmpty>Ничего не найдено</CommandEmpty>
<CommandGroup heading="Навигация"> <CommandGroup heading="Навигация">
<CommandItem onSelect={() => go('/dashboard')}> <CommandItem onSelect={() => go('/dashboard')}>
<LayoutDashboardIcon /> <LayoutDashboardIcon />
Дашборд <span>Дашборд</span>
</CommandItem> </CommandItem>
<CommandItem onSelect={() => go('/vps')}> <CommandItem onSelect={() => go('/vps')}>
<ServerIcon /> <ServerIcon />
Все VPS <span>Все VPS</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
<CommandSeparator /> <CommandSeparator />
<CommandGroup heading="VPS"> <CommandGroup heading="VPS">
{vpsItems.slice(0, 50).map((v) => ( {vpsItems.slice(0, 50).map((v) => (
<CommandItem key={v.id} value={`${v.ip} ${v.dns} ${v.project}`} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}> <CommandItem
<ServerIcon /> key={v.id}
<span>{v.ip || v.dns || v.id}</span> value={`${v.ip} ${v.dns} ${v.project}`}
{v.project ? <span className="text-muted-foreground text-xs">· {v.project}</span> : null} onSelect={() => go('/vps/$vpsId', { vpsId: v.id })}
</CommandItem> >
))} <ServerIcon />
</CommandGroup> <span className="truncate">{v.ip || v.dns || v.id}</span>
<CommandGroup heading="Аккаунты"> {v.project ? (
{accountItems.map((a) => ( <span className="text-muted-foreground text-xs">{v.project}</span>
<CommandItem ) : null}
key={a.id}
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
onSelect={() => go('/accounts')}
>
<WalletIcon />
{a.name}
</CommandItem>
))}
</CommandGroup>
<CommandGroup heading="Проекты">
{projectItems.map((p) => {
const row = p as { id: string; name: string }
return (
<CommandItem key={row.id} value={row.name} onSelect={() => go('/vps', { project: row.name })}>
<FolderKanbanIcon />
{row.name}
</CommandItem> </CommandItem>
) ))}
})}
</CommandGroup> </CommandGroup>
<CommandSeparator />
<CommandGroup heading="Аккаунты">
{accountItems.map((a) => (
<CommandItem
key={a.id}
value={`${a.name} ${providerById.get(a.providerId)?.name ?? ''}`}
onSelect={() => go('/accounts')}
>
<WalletIcon />
<span className="truncate">{a.name}</span>
{providerById.get(a.providerId)?.name ? (
<span className="text-muted-foreground text-xs">
{providerById.get(a.providerId)?.name}
</span>
) : null}
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Проекты">
{projectItems.map((p) => {
const row = p as { id: string; name: string }
return (
<CommandItem
key={row.id}
value={row.name}
onSelect={() => go('/vps', { project: row.name })}
>
<FolderKanbanIcon />
<span className="truncate">{row.name}</span>
</CommandItem>
)
})}
</CommandGroup>
<CommandSeparator />
<CommandGroup heading="Хостеры"> <CommandGroup heading="Хостеры">
{(snapshot?.providers ?? []).map((p) => ( {(snapshot?.providers ?? []).map((p) => (
<CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}> <CommandItem key={p.id} value={p.name} onSelect={() => go('/providers')}>
<Building2Icon /> <Building2Icon />
{p.name} <span className="truncate">{p.name}</span>
</CommandItem> </CommandItem>
))} ))}
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>
</Command> </Command>
+9 -2
View File
@@ -108,8 +108,15 @@ export const api = {
}), }),
fetchSyncStatus: () => fetchApi('/api/sync/status'), fetchSyncStatus: () => fetchApi('/api/sync/status'),
sendTelegramTest: () => sendTelegramTest: (body?: {
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', { method: 'POST' }), telegramBotToken?: string
telegramChatId?: string
telegramMessageThreadId?: string
}) =>
fetchApi<{ ok: boolean; error?: string }>('/api/settings/telegram/test', {
method: 'POST',
body: JSON.stringify(body ?? {}),
}),
sendWebhookTest: () => sendWebhookTest: () =>
fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }), fetchApi<{ ok: boolean; error?: string }>('/api/settings/webhook/test', { method: 'POST' }),
+41 -8
View File
@@ -42,7 +42,7 @@ function settingsToFormValues(s: Settings): SettingsFormValues {
syncIntervalMinutes: s.syncIntervalMinutes ?? 60, syncIntervalMinutes: s.syncIntervalMinutes ?? 60,
syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440, syncTariffsIntervalMinutes: s.syncTariffsIntervalMinutes ?? 1440,
telegramChatId: s.telegramChatId ?? '', telegramChatId: s.telegramChatId ?? '',
telegramBotToken: s.telegramBotToken ?? '', telegramBotToken: '',
notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false, notifyPaymentExpiryEnabled: s.notifyPaymentExpiryEnabled !== false,
notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false, notifyNewTariffsEnabled: s.notifyNewTariffsEnabled !== false,
notifyLowBalanceEnabled: s.notifyLowBalanceEnabled !== 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({ function BoolSelect({
id, id,
label, label,
@@ -96,7 +116,7 @@ function SettingsPage() {
const upsertMut = useMutation({ const upsertMut = useMutation({
mutationFn: (patch: SettingsFormValues) => { mutationFn: (patch: SettingsFormValues) => {
const payload = { ...patch } const payload = buildSettingsSavePayload(patch)
if (current?.id) return api.update<Settings>('settings', current.id, payload) if (current?.id) return api.update<Settings>('settings', current.id, payload)
return api.create<Settings>('settings', { return api.create<Settings>('settings', {
id: 'settings-main', id: 'settings-main',
@@ -114,15 +134,15 @@ function SettingsPage() {
}) })
const telegramTestMut = useMutation({ const telegramTestMut = useMutation({
mutationFn: () => api.sendTelegramTest(), mutationFn: () => api.sendTelegramTest(buildTelegramTestPayload(form.getValues())),
onSuccess: (data) => { onSuccess: (data) => {
if (!data.ok) { if (!data.ok) {
toast.error(data.error ?? 'Ошибка Telegram') toast.error(data.error ?? 'Ошибка Telegram', { duration: 10_000 })
return return
} }
toast.success('Тестовое сообщение отправлено') 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({ const webhookTestMut = useMutation({
@@ -319,7 +339,10 @@ function SettingsPage() {
<Input <Input
id="set-tg-token" id="set-tg-token"
type="password" type="password"
placeholder="123456:ABC-DEF..." autoComplete="new-password"
placeholder={
current?.telegramBotTokenSet ? 'Токен установлен — введите новый для замены' : '123456:ABC-DEF...'
}
{...form.register('telegramBotToken')} {...form.register('telegramBotToken')}
/> />
</FormField> </FormField>
@@ -465,10 +488,16 @@ function SettingsPage() {
<th className="px-3 py-2 font-medium">Событие</th> <th className="px-3 py-2 font-medium">Событие</th>
<th className="px-3 py-2 font-medium">Канал</th> <th className="px-3 py-2 font-medium">Канал</th>
<th className="px-3 py-2 font-medium">Статус</th> <th className="px-3 py-2 font-medium">Статус</th>
<th className="px-3 py-2 font-medium">Ошибка</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{notificationRows.map((row) => ( {notificationRows.map((row) => {
const errorText =
row.status === 'failed' && row.payload?.error != null
? String(row.payload.error)
: ''
return (
<tr key={row.id} className="border-b last:border-0"> <tr key={row.id} className="border-b last:border-0">
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground"> <td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{new Date(row.createdAt).toLocaleString('ru-RU')} {new Date(row.createdAt).toLocaleString('ru-RU')}
@@ -476,8 +505,12 @@ function SettingsPage() {
<td className="px-3 py-2">{row.event}</td> <td className="px-3 py-2">{row.event}</td>
<td className="px-3 py-2">{row.channel}</td> <td className="px-3 py-2">{row.channel}</td>
<td className="px-3 py-2">{row.status}</td> <td className="px-3 py-2">{row.status}</td>
<td className="max-w-xs px-3 py-2 text-xs text-destructive break-words">
{errorText || '—'}
</td>
</tr> </tr>
))} )
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -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')
})
})
+3 -1
View File
@@ -95,7 +95,9 @@ function buildValues(id: string, existing: Row | undefined, r: SettingsInput) {
? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440) ? Math.max(60, Number(r.syncTariffsIntervalMinutes) || 1440)
: existing?.syncTariffsIntervalMinutes ?? 1440, : existing?.syncTariffsIntervalMinutes ?? 1440,
telegramBotToken: telegramBotToken:
r.telegramBotToken !== undefined ? r.telegramBotToken || '' : existing?.telegramBotToken ?? '', r.telegramBotToken !== undefined && String(r.telegramBotToken || '').trim() !== ''
? r.telegramBotToken
: existing?.telegramBotToken ?? '',
telegramChatId: telegramChatId:
r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '', r.telegramChatId !== undefined ? r.telegramChatId || '' : existing?.telegramChatId ?? '',
telegramMessageThreadId: telegramMessageThreadId:
@@ -26,3 +26,11 @@ export const settingsSchema = z.object({
}) })
export type Settings = z.infer<typeof settingsSchema> export type Settings = z.infer<typeof settingsSchema>
export const telegramTestBodySchema = z.object({
telegramBotToken: z.string().optional(),
telegramChatId: z.string().optional(),
telegramMessageThreadId: z.string().optional(),
})
export type TelegramTestBody = z.infer<typeof telegramTestBodySchema>
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react" import * as React from "react"
import { Command as CommandPrimitive } from "cmdk" import { Command as CommandPrimitive } from "cmdk"
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react" import * as React from "react"
import { Dialog as DialogPrimitive } from "@base-ui/react/dialog" import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
@@ -1,3 +1,5 @@
"use client"
import * as React from "react" import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority" import { cva, type VariantProps } from "class-variance-authority"