feat(api): Telegram — темы, очередь повторов, «Система», /setup

telegram/client: Bot API на fetch с таймаутом 5 с; ошибки делятся на
повторяемые (сеть, таймаут, 429, 5xx) и конфигурационные (400/403…),
токен не попадает ни в тексты ошибок, ни в tg_last_error.
format: заявка и уточнение по шаблону плана, HTML с экранированием,
источник из utm / referrer, первый визит.
outbox: Notifier с отправкой после ответа и тиком раз в минуту, пауза
1, 2, 4… мин, уточнение только после своей заявки, после 10 попыток —
в «Систему». system: тема «Система» с троттлингом ×N за период.
bot: long polling, только TELEGRAM_ADMIN_ID, /setup создаёт темы и
присылает id в личку. config: всё окружение в одном месте.
This commit is contained in:
2026-10-17 16:07:26 +03:00
parent 5d970be228
commit 667c6a6e6d
15 changed files with 1316 additions and 22 deletions
+143
View File
@@ -0,0 +1,143 @@
// Тонкий клиент 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>;
const API = 'https://api.telegram.org';
export function createTelegramClient({
token,
fetch = globalThis.fetch,
timeoutMs = 5000,
}: {
token: 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, `${API}/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', `${API}/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;
},
};
}