Files
vps-tracker/apps/api/src/services/telegram.ts
T
DenozordecandCursor 05e7bf829e
Docker / build (push) Has been cancelled
feat(api, web): тест Telegram из формы, подсказки ошибок API и UX настроек
Тест отправки использует значения формы без предварительного сохранения; пустой токен при сохранении не затирает сохранённый. Добавлены подсказки по частым ошибкам Telegram API и колонка ошибок в журнале уведомлений.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-29 00:54:51 +07:00

113 lines
3.6 KiB
TypeScript

/**
* Telegram Bot API — отправка уведомлений
*/
export interface TelegramSendResult {
ok: boolean
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[],
text: string,
messageThreadId?: string | number | null,
): Promise<TelegramSendResult> {
if (!token?.trim() || !text?.trim()) {
return { ok: false, error: 'Пустой токен или текст' }
}
const ids = Array.isArray(chatIds)
? chatIds
: String(chatIds || '')
.split(',')
.map((id) => id.trim())
.filter(Boolean)
if (ids.length === 0) return { ok: false, error: 'Не указан chat ID' }
const threadId = messageThreadId != null && messageThreadId !== '' ? Number(messageThreadId) : null
const payload: Record<string, unknown> = {
text,
parse_mode: 'HTML',
disable_web_page_preview: true,
}
if (Number.isFinite(threadId)) payload.message_thread_id = threadId
const url = `https://api.telegram.org/bot${token.trim()}/sendMessage`
const errors: string[] = []
let anyOk = false
for (const chatId of ids) {
try {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...payload, chat_id: chatId }),
})
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 = formatTelegramApiError(chatId, res, data, rawBody)
errors.push(err)
console.warn(`Telegram sendMessage failed for chat ${chatId}:`, err)
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
errors.push(`${chatId}: ${message}`)
console.warn(`Telegram sendMessage error for chat ${chatId}:`, message)
}
}
if (anyOk) return { ok: true }
return { ok: false, error: errors.join('; ') || 'Не удалось отправить' }
}