feat(api, web): тест Telegram из формы, подсказки ошибок API и UX настроек
Docker / build (push) Has been cancelled
Docker / build (push) Has been cancelled
Тест отправки использует значения формы без предварительного сохранения; пустой токен при сохранении не затирает сохранённый. Добавлены подсказки по частым ошибкам Telegram API и колонка ошибок в журнале уведомлений. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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/')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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' }
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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<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(
|
||||
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) {
|
||||
|
||||
Reference in New Issue
Block a user