// Тонкий клиент Bot API на fetch. URL содержит токен, поэтому ни URL, ни исходные // ошибки fetch наружу не отдаём: тексты TelegramError собираются только из метода и ответа API. export type TelegramErrorKind = 'retryable' | 'config'; export class TelegramError extends Error { constructor( message: string, readonly kind: TelegramErrorKind, /** HTTP-код ответа API; нет у сетевых ошибок и таймаута */ readonly code?: number, readonly retryAfterSec?: number, ) { super(message); this.name = 'TelegramError'; } } export type FetchLike = (url: string, init: RequestInit) => Promise; export type TgUser = { id: number; is_bot: boolean; first_name: string; username?: string }; export type TgChat = { id: number; type: 'private' | 'group' | 'supergroup' | 'channel'; title?: string; is_forum?: boolean }; export type TgDocument = { file_id: string; file_name?: string; file_size?: number }; export type TgMessage = { message_id: number; message_thread_id?: number; from?: TgUser; chat: TgChat; text?: string; document?: TgDocument; }; export type TgCallbackQuery = { id: string; from: TgUser; message?: TgMessage; data?: string }; export type TgUpdate = { update_id: number; message?: TgMessage; callback_query?: TgCallbackQuery; my_chat_member?: { chat: TgChat; from: TgUser }; }; export type TgChatMember = { status: string; can_manage_topics?: boolean }; export type InlineKeyboard = { inline_keyboard: { text: string; callback_data: string }[][] }; export type SendMessageParams = { chat_id: number; text: string; message_thread_id?: number; parse_mode?: 'HTML'; reply_markup?: InlineKeyboard; disable_web_page_preview?: boolean; }; export type TelegramClient = ReturnType; // С VPS в РФ api.telegram.org недоступен — ходим через ретранслятор (TELEGRAM_API_BASE, см. deploy/tg-relay). export const DEFAULT_API_BASE = 'https://api.telegram.org'; export function createTelegramClient({ token, apiBase = DEFAULT_API_BASE, fetch = globalThis.fetch, timeoutMs = 5000, }: { token: string; apiBase?: string; fetch?: FetchLike; timeoutMs?: number; }) { async function request(method: string, url: string, init: RequestInit, timeout: number, signal?: AbortSignal) { const timeoutSignal = AbortSignal.timeout(timeout); try { return await fetch(url, { ...init, signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal }); } catch (err) { if (signal?.aborted) throw new TelegramError(`${method}: отменено`, 'retryable'); const reason = timeoutSignal.aborted ? 'таймаут' : `сеть (${err instanceof Error ? err.name : 'unknown'})`; throw new TelegramError(`${method}: ${reason}`, 'retryable'); } } async function call( method: string, params: Record | FormData = {}, opts: { timeoutMs?: number; signal?: AbortSignal } = {}, ): Promise { const init: RequestInit = params instanceof FormData ? { method: 'POST', body: params } : { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(params) }; const res = await request(method, `${apiBase}/bot${token}/${method}`, init, opts.timeoutMs ?? timeoutMs, opts.signal); let body: { ok?: boolean; result?: T; description?: string; parameters?: { retry_after?: number } }; try { body = (await res.json()) as typeof body; } catch { body = {}; } if (res.ok && body.ok) return body.result as T; const description = (body.description ?? res.statusText).replaceAll(token, '***'); const message = `${method}: ${res.status} ${description}`; if (res.status === 429) { throw new TelegramError(message, 'retryable', 429, body.parameters?.retry_after ?? 1); } if (res.status >= 500) throw new TelegramError(message, 'retryable', res.status); // 400/401/403/404/409: дело в настройке (чат, тема, права, токен) — повтор сам не поможет. throw new TelegramError(message, 'config', res.status); } return { call, getMe: () => call('getMe'), sendMessage: (params: SendMessageParams) => call('sendMessage', params), sendDocument(params: { chat_id: number; file: { name: string; data: Uint8Array }; caption?: string; message_thread_id?: number; }) { const form = new FormData(); form.set('chat_id', String(params.chat_id)); if (params.message_thread_id !== undefined) form.set('message_thread_id', String(params.message_thread_id)); if (params.caption) form.set('caption', params.caption); form.set('document', new Blob([new Uint8Array(params.file.data)]), params.file.name); // Файл бэкапа может идти дольше обычного сообщения. return call('sendDocument', form, { timeoutMs: Math.max(timeoutMs, 60_000) }); }, getUpdates: ( params: { offset?: number; timeout: number; allowed_updates: string[] }, signal?: AbortSignal, ) => call('getUpdates', params, { timeoutMs: (params.timeout + 10) * 1000, signal }), createForumTopic: (chat_id: number, name: string) => call<{ message_thread_id: number; name: string }>('createForumTopic', { chat_id, name }), getChat: (chat_id: number) => call('getChat', { chat_id }), getChatMember: (chat_id: number, user_id: number) => call('getChatMember', { chat_id, user_id }), answerCallbackQuery: (callback_query_id: string, text?: string) => call('answerCallbackQuery', { callback_query_id, text }), editMessageText: (params: { chat_id: number; message_id: number; text: string; reply_markup?: InlineKeyboard }) => call('editMessageText', params), getFile: (file_id: string) => call<{ file_id: string; file_path?: string; file_size?: number }>('getFile', { file_id }), async downloadFile(filePath: string, maxBytes: number): Promise { const res = await request('downloadFile', `${apiBase}/file/bot${token}/${filePath}`, {}, 60_000); if (!res.ok) throw new TelegramError(`downloadFile: ${res.status}`, res.status >= 500 ? 'retryable' : 'config', res.status); const data = new Uint8Array(await res.arrayBuffer()); if (data.byteLength > maxBytes) throw new TelegramError('downloadFile: файл больше лимита', 'config'); return data; }, }; }