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>
This commit is contained in:
zabudko-ruandClaude Opus 5.5 committed 2026-10-21 01:50:37 +04:00
1 parent d452824c62
commit 23ffb2bf1d
46 files changed
+4176 -144

No files matched your search

+37 -3
View File
@@ -25,11 +25,12 @@ export class TelegramSender {
private label: string,
) {}
async send(msg: OutgoingMessage): Promise<void> {
/** 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;
return true;
}
const res = await fetch(`https://api.telegram.org/bot${this.token}/sendMessage`, {
method: 'POST',
@@ -48,10 +49,43 @@ export class TelegramSender {
// (например, тестовые пользователи seed); повторять бессмысленно
if (res.status === 403 || (res.status === 400 && body.includes('chat not found'))) {
console.warn(`[${this.label}] chat ${msg.chatId} unreachable: ${res.status}`);
return;
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;
}
}