С VPS api.telegram.org недоступен. deploy/tg-relay: nginx на адресе Tailscale домашнего сервера, пропускает только запросы нашего бота из tailnet, без логов доступа (токен в URL). api: базовый URL Bot API из TELEGRAM_API_BASE; сетевые сбои polling пишутся в лог раз в 10 минут.
147 lines
6.6 KiB
TypeScript
147 lines
6.6 KiB
TypeScript
// Тонкий клиент 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;
|
||
},
|
||
};
|
||
}
|