Files
BarevClub/packages/services/src/telegram.ts
T
zabudko-ruandClaude Opus 5.5 23ffb2bf1d Backoffice stage 2: chats (support + concierge, files)
- ChatThread per client/provider and channel; messages get files, internal notes,
  source (bot/mini app) and delivery errors; reply templates. Migration moves old
  concierge messages into threads.
- Routing: subscribers write to the concierge, others to support; /support and
  /concierge in the client bot; staff can take, transfer, close and move threads.
- Telegram: photos/documents from clients and providers, replies with the channel
  header, files sent from storage, notifications only to the assignee or the
  channel's staff, "Reply" into a specific thread.
- New admin: /chats with list, conversation, notes, files, templates, client panel;
  "Message" button on the client card; dashboard counts threads awaiting reply.
- Client mini app: in-app chat screen sharing the same conversation.
- Old admin keeps working on the same data (notes hidden there).
- Tests for routing, notes, assign/close/move, lists, recipients; separate test DB
  via TEST_DB_NAME; dev:chats-demo script.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-21 01:50:37 +04:00

93 lines
4.5 KiB
TypeScript

/**
* Отправка сообщений через Telegram Bot API без зависимостей (воркер).
* Без токена (локальная разработка) сообщение печатается в консоль.
*/
export type InlineButton = { text: string; callback_data: string } | { text: string; web_app: { url: string } } | { text: string; url: string };
export interface OutgoingMessage {
chatId: bigint | number | string;
text: string;
buttons?: InlineButton[][];
}
/** Telegram принимает web_app-кнопки только с https — в разработке такие кнопки опускаются */
export function webAppButton(text: string, url: string): InlineButton | null {
return url.startsWith('https://') ? { text, web_app: { url } } : null;
}
export function compactRows(rows: (InlineButton | null)[][]): InlineButton[][] {
return rows.map((r) => r.filter((b): b is InlineButton => !!b)).filter((r) => r.length > 0);
}
export class TelegramSender {
constructor(
private token: string,
private label: string,
) {}
/** false — получатель недоступен (заблокировал бота / бот ему не писал) */
async send(msg: OutgoingMessage): Promise<boolean> {
if (!this.token) {
const buttons = msg.buttons?.flat().map((b) => `[${b.text}]`).join(' ') ?? '';
console.log(`[${this.label} → ${msg.chatId}] ${msg.text.replace(/<[^>]+>/g, '')} ${buttons}`);
return true;
}
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
chat_id: String(msg.chatId),
text: msg.text,
parse_mode: 'HTML',
link_preview_options: { is_disabled: true },
reply_markup: msg.buttons?.length ? { inline_keyboard: msg.buttons } : undefined,
}),
});
if (!res.ok) {
const body = await res.text();
// 403 — пользователь заблокировал бота, 400 chat not found — бот ему ещё не писал
// (например, тестовые пользователи seed); повторять бессмысленно
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
return false;
}
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Файл с подписью: картинки — как фото (видно сразу), остальное — документом. false — получатель недоступен */
async sendFile(msg: { chatId: bigint | number | string; caption: string; body: Buffer; fileName: string; mime: string }): Promise<boolean> {
const asPhoto = /^image\/(jpeg|png|webp)$/.test(msg.mime) && msg.body.length <= 10 * 1024 * 1024;
if (!this.token) {
console.log(`[${this.label} → ${msg.chatId}] 📎 ${msg.fileName} (${msg.body.length} B) ${msg.caption.replace(/<[^>]+>/g, '')}`);
return true;
}
const form = new FormData();
form.set('chat_id', String(msg.chatId));
form.set('caption', msg.caption.slice(0, 1024));
form.set('parse_mode', 'HTML');
form.set(asPhoto ? 'photo' : 'document', new Blob([new Uint8Array(msg.body)], { type: msg.mime }), msg.fileName);
const res = await fetch(`https://api.telegram.org/bot${this.token}/${asPhoto ? 'sendPhoto' : 'sendDocument'}`, { method: 'POST', body: form });
if (!res.ok) {
const body = await res.text();
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) return false;
throw new Error(`Telegram ${res.status}: ${body}`);
}
return true;
}
/** Скачать файл, который прислали боту (до 20 МБ — ограничение Bot API) */
async download(fileId: string): Promise<Response | null> {
if (!this.token) return null;
const info = await fetch(`https://api.telegram.org/bot${this.token}/getFile?file_id=${encodeURIComponent(fileId)}`);
if (!info.ok) return null;
const json = (await info.json()) as { ok: boolean; result?: { file_path?: string } };
if (!json.result?.file_path) return null;
const file = await fetch(`https://api.telegram.org/file/bot${this.token}/${json.result.file_path}`);
return file.ok ? file : null;
}
}
export const escapeHtml = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');