Files
kvarta_project/apps/api/src/telegram/client.ts
T
nunuchek 66d69c1245 feat: ретранслятор Bot API на домашнем сервере и TELEGRAM_API_BASE
С VPS api.telegram.org недоступен. deploy/tg-relay: nginx на адресе
Tailscale домашнего сервера, пропускает только запросы нашего бота из
tailnet, без логов доступа (токен в URL). api: базовый URL Bot API из
TELEGRAM_API_BASE; сетевые сбои polling пишутся в лог раз в 10 минут.
2026-10-17 16:56:52 +03:00

147 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Тонкий клиент 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<Response>;
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<typeof createTelegramClient>;
// С 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<T>(
method: string,
params: Record<string, unknown> | FormData = {},
opts: { timeoutMs?: number; signal?: AbortSignal } = {},
): Promise<T> {
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<TgUser>('getMe'),
sendMessage: (params: SendMessageParams) => call<TgMessage>('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<TgMessage>('sendDocument', form, { timeoutMs: Math.max(timeoutMs, 60_000) });
},
getUpdates: (
params: { offset?: number; timeout: number; allowed_updates: string[] },
signal?: AbortSignal,
) => call<TgUpdate[]>('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<TgChat>('getChat', { chat_id }),
getChatMember: (chat_id: number, user_id: number) => call<TgChatMember>('getChatMember', { chat_id, user_id }),
answerCallbackQuery: (callback_query_id: string, text?: string) =>
call<boolean>('answerCallbackQuery', { callback_query_id, text }),
editMessageText: (params: { chat_id: number; message_id: number; text: string; reply_markup?: InlineKeyboard }) =>
call<TgMessage | boolean>('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<Uint8Array> {
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;
},
};
}