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:
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user